diff --git a/.gitignore b/.gitignore index c78fc6d..ee9d625 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,11 @@ /vendor/ /var/ # Composer -/composer.phar +composer.phar # IDE configuration -/.idea/** -/.vscode/** +.idea/** +.vscode/** .DS_Store # Testing @ QA @@ -16,12 +16,12 @@ .codacy/logs/ # Environment -/.env -/clover.xml -/.codacy +.env +clover.xml +.codacy # Documentation -venv/** +/venv/** .cache/** site/** .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 58865d4..6a47563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ **Présentation des versions du framework Edu Framework** +## v2.4.0 - 08/10/2026 + +**new features** + +- Un controller peut maintenant gérer plusieurs routes via plusieurs méthodes : la clé `controller` du fichier "app/Config/routes.yaml" accepte la syntaxe `Controller\VilleController::index`. Sans méthode explicite, la méthode `execute()` est appelée comme avant (100 % rétro-compatible) +- La méthode appelée est validée par le framework (publique, non statique, paramètre de type Request, retour string|null) et lève une `ErrorControllerException` avec un message explicite en cas d'erreur +- [#75](https://github.com/studoo-app/edu-framework/issues/75) Gestion des fichiers téléversés (`$_FILES`) dans `Request` : normalisation automatique de la structure (champ simple et multi-fichiers), méthodes `hasFile()`, `getFile()`, `getFiles()`, `isValid()`, `getExtension()` et `move()`, démo applicative `/medecin/import` et documentation complète +- [#64](https://github.com/studoo-app/edu-framework/issues/64) Activation du cache TWIG : les templates compilés sont stockés dans `var/cache/twig` (nouvelle clé de configuration `cache_path`), recompilation automatique des templates modifiés (`auto_reload`) +- [#65](https://github.com/studoo-app/edu-framework/issues/65) Nouvelle commande `php bin/edu cache:clear` pour supprimer le cache de l'application (dossier `var/cache`) +- Documentation : nouvelle section "Un controller, plusieurs routes" dans [docs/build/controller-edu.md](docs/build/controller-edu.md) + +**bug Fixes** + +- Le fichier de configuration des routes est vérifié avant lecture : une exception `ErrorRouteConfigNotExistException` avec un message explicite (chemin attendu, configuration `route_config_path`, sensibilité à la casse sur Linux) remplace le fatal error du composant Yaml quand "routes.yaml" est introuvable + ## v2.3.2 - 05/10/2026 **new features** diff --git a/app/Config/routes.yaml b/app/Config/routes.yaml index 6714cb9..bae223a 100644 --- a/app/Config/routes.yaml +++ b/app/Config/routes.yaml @@ -21,4 +21,16 @@ userUpdate: userName: uri: /user/{id:\d+}[/{name}] controller: Controller\UserController + httpMethod: [GET,POST] +medecin: + uri: /medecin + controller: Controller\MedecinController::index + httpMethod: [GET] +medecin_new: + uri: /medecin/new + controller: Controller\MedecinController::new + httpMethod: [GET,POST] +medecin_import: + uri: /medecin/import + controller: Controller\MedecinController::import httpMethod: [GET,POST] \ No newline at end of file diff --git a/app/Controller/MedecinController.php b/app/Controller/MedecinController.php new file mode 100644 index 0000000..f6b6d3e --- /dev/null +++ b/app/Controller/MedecinController.php @@ -0,0 +1,86 @@ +render('medecin/index.html.twig', + [ + "titre" => 'MedecinController - index', + "request" => $request + ] + ); + } + + public function new(Request $request): string|null + { + return TwigCore::getEnvironment()->render('medecin/new.html.twig', + [ + "titre" => 'MedecinController - new', + "request" => $request + ] + ); + } + + /** + * Démo de gestion d'un téléversement de fichier (upload) + * GET : affiche le formulaire d'import + * POST : contrôle le fichier téléversé puis le déplace dans le dossier public/upload/ + */ + public function import(Request $request): string|null + { + $message = null; + $erreur = null; + + if ($request->getHttpMethod() === "POST") { + if ($request->hasFile('fichier') === false) { + $erreur = "Aucun fichier n'a été envoyé"; + } elseif ($request->isValid('fichier') === false) { + $erreur = "Le fichier n'est pas valide (taille trop grande ou téléversement interrompu)"; + } elseif (in_array($request->getExtension('fichier'), ['csv', 'txt'], true) === false) { + // Sécurité : liste blanche des extensions autorisées + $erreur = "Extension non autorisée (seuls les fichiers csv et txt sont acceptés)"; + } else { + // Le dossier d'upload est créé s'il n'existe pas + $dossierUpload = ConfigCore::getConfig('base_path') . 'public/upload'; + if (is_dir($dossierUpload) === false) { + mkdir($dossierUpload, 0777, true); + } + + // Sécurité : le nom du fichier est maîtrisé côté serveur + $destination = $dossierUpload . '/medecin-import-' . uniqid() . '.' + . $request->getExtension('fichier'); + + if ($request->move('fichier', $destination) === true) { + $message = "Le fichier a bien été téléversé : " . basename($destination); + } else { + $erreur = "Problème lors de l'enregistrement du fichier"; + } + } + } + + return TwigCore::getEnvironment()->render('medecin/import.html.twig', + [ + "titre" => 'MedecinController - import', + "request" => $request, + "message" => $message, + "erreur" => $erreur + ] + ); + } + + /** + * Méthode privée pour les tests de validation du framework (méthode non publique) + * @return string|null + */ + private function testPrivate(Request $request): string|null + { + return null; + } +} diff --git a/app/Template/medecin/import.html.twig b/app/Template/medecin/import.html.twig new file mode 100644 index 0000000..5d3212b --- /dev/null +++ b/app/Template/medecin/import.html.twig @@ -0,0 +1,19 @@ +{% extends "base.html.twig" %} + +{% block title %}{{ titre }}{% endblock %} + +{% block content %} +

{{ titre }}

+ +{% if message %} +

{{ message }}

+{% endif %} +{% if erreur %} +

{{ erreur }}

+{% endif %} + +
+ + +
+{% endblock %} diff --git a/app/Template/medecin/index.html.twig b/app/Template/medecin/index.html.twig new file mode 100644 index 0000000..de02b24 --- /dev/null +++ b/app/Template/medecin/index.html.twig @@ -0,0 +1,7 @@ +{% extends "base.html.twig" %} + +{% block title %}{{ titre }}{% endblock %} + +{% block content %} +

{{ titre }}

+{% endblock %} diff --git a/app/Template/medecin/new.html.twig b/app/Template/medecin/new.html.twig new file mode 100644 index 0000000..de02b24 --- /dev/null +++ b/app/Template/medecin/new.html.twig @@ -0,0 +1,7 @@ +{% extends "base.html.twig" %} + +{% block title %}{{ titre }}{% endblock %} + +{% block content %} +

{{ titre }}

+{% endblock %} diff --git a/compose.yaml b/compose.yaml index 6ddec08..630f5a6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,18 +1,18 @@ services: database: container_name: edu-web-database - image: mysql:8.0 - command: mysqld --sql_mode="STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION" - platform: linux/amd64 + image: mariadb:latest ports: - "3306:3306" restart: always environment: MYSQL_DATABASE: app_db - MYSQL_ROOT_PASSWORD: studoo # Si tu changes le mot de passe root, change aussi le mot de passe dans le fichier .env et dans la config du service dbgate - #MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' - MYSQL_USER: app_db_user - MYSQL_PASSWORD: app_db_password + MYSQL_ROOT_PASSWORD: studoo + MYSQL_USER: stud + MYSQL_PASSWORD: studoo + # Si tu changes le mot de passe root ou stud, change aussi le mot de passe dans le fichier .env et dans la config du service dbgate +# MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + TZ: Europe/Paris volumes: - ./var/dbdata:/var/lib/mysql mailer: @@ -32,7 +32,7 @@ services: - 8082:3000 volumes: - ./var/dbgate:/root/.dbgate - - ./var/sqlite:/home/jan + - ./var/logs:/home/jan environment: CONNECTIONS: con1,con2 @@ -44,7 +44,7 @@ services: ENGINE_con1: mysql@dbgate-plugin-mysql LABEL_con2: SQLite - FILE_con2: /home/jan/app_db.sqlite + FILE_con2: /home/jan/core_logs.sqlite ENGINE_con2: sqlite@dbgate-plugin-sqlite phpmyadmin: diff --git a/docs/boost/resquet.md b/docs/boost/resquet.md index 588094b..ea7a924 100644 --- a/docs/boost/resquet.md +++ b/docs/boost/resquet.md @@ -183,3 +183,139 @@ Résultat : ``` /inscription ``` + +## Gestion des fichiers (upload) + +Request permet de gérer les fichiers envoyés via un formulaire HTML avec l'attribut `enctype="multipart/form-data"`. + +!!! warning "Formulaire obligatoire" + + Pour envoyer des fichiers, le formulaire HTML **doit** avoir l'attribut `enctype="multipart/form-data"` : + + ```html +
+ + +
+ ``` + +### hasFile() +Permet de savoir si le formulaire a envoyé un fichier pour ce champ. + +```php +if ($request->hasFile('fichier')) { + // Un fichier a été envoyé pour le champ "fichier" +} +``` + +!!! warning "Champ laissé vide" + + Un champ file laissé vide génère quand même une entrée dans `$_FILES` avec `error = 4` (`UPLOAD_ERR_NO_FILE`). + `hasFile()` retourne `true` mais `isValid()` retourne `false`. + +### getFile() +Renvoie la **liste** des fichiers téléversés pour un champ du formulaire, ou `null` si le champ n'existe pas. + +```php +$fichiers = $request->getFile('fichier'); +``` + +Résultat : + +```php +array(1) { + [0]=> + array(5) { + ["name"]=> string(12) "medecins.csv" + ["type"]=> string(9) "text/csv" + ["size"]=> int(123) + ["tmp_name"]=> string(14) "/tmp/phpXYZ" + ["error"]=> int(0) + } +} +``` + +#### Pourquoi une liste ? La normalisation de $_FILES + +En PHP natif, la structure de `$_FILES` change complètement de forme selon le champ HTML : + +| Champ HTML | Structure native de `$_FILES` | +|---|---| +| `` | `$_FILES['fichier']['name']` est une **chaine** | +| `` | `$_FILES['fichiers']['name']` est un **tableau** (`type`, `size`, `tmp_name`, `error` aussi) | + +Le framework **normalise** cette structure à l'injection : chaque champ pointe toujours vers une **liste** de fichiers, avec les mêmes clés (`name`, `type`, `size`, `tmp_name`, `error`). Vous n'avez plus à gérer les deux formes. + +### getFiles() +Renvoie l'ensemble des fichiers téléversés de la requête (structure normalisée : chaque champ → une liste de fichiers). + +```php +$tousLesFichiers = $request->getFiles(); +``` + +### isValid() +Vérifie que le(s) fichier(s) du champ sont valides, c'est-à-dire que le téléversement s'est terminé sans erreur (`error = 0`, constante `UPLOAD_ERR_OK`). + +```php +// Vérifie tous les fichiers du champ +$request->isValid('fichier'); + +// Vérifie uniquement le fichier à l'index 1 (champ multi-fichiers) +$request->isValid('fichiers', 1); +``` + +### getExtension() +Renvoie l'extension du fichier téléversé (en minuscules), ou `null` si le fichier est absent ou invalide. + +```php +$extension = $request->getExtension('fichier'); // "csv" +``` + +### move() +Déplace le fichier téléversé depuis son emplacement temporaire (`tmp_name`) vers sa destination finale. +Cette méthode encapsule la fonction PHP `move_uploaded_file()` en vérifiant au préalable que le téléversement est valide. Elle retourne `false` en cas de problème. + +```php +$destination = __DIR__ . '/../../public/upload/mon-fichier.csv'; +if ($request->move('fichier', $destination) === true) { + // Le fichier a été déplacé +} +``` + +## Exemple complet + +Un exemple complet et fonctionnel est disponible dans le framework : +la route [/medecin/import](http://localhost:8042/medecin/import) permet de téléverser un fichier CSV ou TXT. + +```php +public function import(Request $request): string|null +{ + $message = null; + $erreur = null; + + if ($request->getHttpMethod() === "POST") { + if ($request->hasFile('fichier') === false) { + $erreur = "Aucun fichier n'a été envoyé"; + } elseif ($request->isValid('fichier') === false) { + $erreur = "Le fichier n'est pas valide (taille trop grande ou téléversement interrompu)"; + } elseif (in_array($request->getExtension('fichier'), ['csv', 'txt'], true) === false) { + $erreur = "Extension non autorisée"; + } else { + $destination = $dossierUpload . '/import-' . uniqid() . '.' . $request->getExtension('fichier'); + if ($request->move('fichier', $destination) === true) { + $message = "Le fichier a bien été téléversé"; + } else { + $erreur = "Problème lors de l'enregistrement du fichier"; + } + } + } + + return TwigCore::getEnvironment()->render('medecin/import.html.twig', [...]); +} +``` + +!!! warning "Sécurité" + + - Vérifiez toujours `isValid()` **et** l'extension côté serveur avec une **liste blanche** (jamais une liste noire) + - Ne faites **jamais** confiance au nom de fichier fourni par le client : générez le nom final côté serveur (Exemple: `uniqid()`) + - Utilisez toujours `move_uploaded_file()` (via `move()`) et non `copy()` : c'est la seule façon sûre de manipuler un fichier téléversé diff --git a/docs/boost/route.md b/docs/boost/route.md index 9039871..a1cdd03 100644 --- a/docs/boost/route.md +++ b/docs/boost/route.md @@ -21,6 +21,12 @@ Cette fonctionnalité est disponible à partir de la version **v2.1.0**. Si vous changez l'`uri` d'une route, tous les liens générés par `getNameToPath()` suivent automatiquement. +!!! info "Plusieurs méthodes dans un même controller" + + Depuis la version **v2.4.0**, la clé `controller` du fichier `app/Config/routes.yaml` peut désigner une méthode précise du controller avec la syntaxe `Controller\MedecinController::index` (voir [Un controller, plusieurs routes](../build/controller-edu.md#un-controller-plusieurs-routes-plusieurs-methodes)). + + Cela ne change rien pour `getNameToPath()` : la génération d'URL se base uniquement sur le **nom** et l'`uri` de la route. + ## Utilisation dans un template Twig `getNameToPath` est une fonction Twig disponible dans tous vos templates : diff --git a/docs/build/controller-edu.md b/docs/build/controller-edu.md index 504e84d..3bea75d 100644 --- a/docs/build/controller-edu.md +++ b/docs/build/controller-edu.md @@ -128,6 +128,79 @@ Si nous allons de nouveau sur l'url [http://localhost:8042/hello](http://localho Pour rappel, l'erreur `HTTP 404 signifie que la ressource demandée (route) n'existe pas ou plus. +## Un controller, plusieurs routes (plusieurs méthodes) + +Jusqu'à présent, une route était associée à un controller possédant une seule méthode `execute()`. +Il est possible de regrouper plusieurs routes dans un même controller, chacune pointant vers une méthode différente. +Ce fonctionnement est disponible à partir de la version **v2.4.0**. + +Pour cela, on ajoute le nom de la méthode à appeler après le nom du controller, séparé par `::` dans le fichier "app/Config/routes.yaml" : + +```yaml +medecin: + uri: /medecin + controller: Controller\MedecinController::index + httpMethod: [GET] +medecin_new: + uri: /medecin/new + controller: Controller\MedecinController::new + httpMethod: [GET, POST] +``` + +La route `/medecin` appelle la méthode `index()` et la route `/medecin/new` appelle la méthode `new()` du controller `MedecinController` : + +```php +render('medecin/index.html.twig', + [ + "titre" => 'Liste des médecins', + "request" => $request + ] + ); + } + + public function new(Request $request): string|null + { + return TwigCore::getEnvironment()->render('medecin/new.html.twig', + [ + "titre" => 'Créer un médecin', + "request" => $request + ] + ); + } +} +``` + +!!! info "La méthode new() est-elle valide ?" + + `new` est un mot réservé du langage PHP, mais il est autorisé comme nom de méthode depuis PHP 7. + +### Les règles à respecter + +Une méthode appelée par une route doit : + +- être **publique** et **non statique**, +- avoir un paramètre de type [Request](../boost/resquet.md), +- retourner une chaine de caractères (`string`) ou `null`. + +Si une de ces règles n'est pas respectée, une exception `ErrorControllerException` est levée avec un message explicite. + +!!! info "Et l'interface ControllerInterface ?" + + - Sans méthode explicite (`Controller\MedecinController`), le controller doit toujours implémenter l'interface `ControllerInterface` et c'est sa méthode `execute()` qui est appelée. Ce comportement reste inchangé : toutes vos routes existantes continuent de fonctionner. + - Avec une méthode explicite (`Controller\MedecinController::index`), l'interface n'est plus requise : le framework vérifie directement la méthode appelée. + + ## Schema pour mieux comprendre le fonctionnement
diff --git a/docs/installation/command-edu.md b/docs/installation/command-edu.md index 7445d58..0ceabda 100644 --- a/docs/installation/command-edu.md +++ b/docs/installation/command-edu.md @@ -37,6 +37,7 @@ Voici les commandes disponibles par version : | make:controller | génération d'un controller | v1.0 | | make:api | génération d'un controller type json API | v2.0 | | make:command | génération d'un commande line | v2.0 | +| cache:clear | suppression du cache de l'application | v2.4.0 | !!! info "Pour aller plus loin" @@ -59,6 +60,28 @@ php bin/edu start --no-start Vous pouvez consulter la page [Start/Stop l'application](start-application.md) pour plus de détails sur la commande `start`. +### La commande cache:clear + +Les templates Twig sont compilés puis mis en cache dans le dossier `var/cache/twig` (à partir de la version v2.4.0). +Pour supprimer le cache de l'application, vous pouvez taper la commande suivante : + +```bash +php bin/edu cache:clear +``` + +| Élément | Détail | +|------------------------|-----------------------------------------------------| +| Dossier concerné | `var/cache` (configurable via la clé `cache_path`) | +| Contenu supprimé | tout le contenu du dossier (Exemple: le cache TWIG) | +| Le dossier lui-même | conservé | + +Si le dossier `var/cache` n'existe pas, un message vous indique qu'il n'y a aucun cache à supprimer. + +!!! info "Recompilation automatique" + + Les templates modifiés sont automatiquement recompilés (option `auto_reload` de Twig). + La commande `cache:clear` reste utile pour repartir d'un cache vierge, par exemple après un déploiement. + ___ ## Si vous rencontrez une erreur diff --git a/docs/installation/installation.md b/docs/installation/installation.md index 10a75c6..2e0c383 100644 --- a/docs/installation/installation.md +++ b/docs/installation/installation.md @@ -139,6 +139,27 @@ Fatal error: Uncaught Dotenv\Exception\InvalidPathException: Unable to read any of the environment file(s) at [...] ```` +### Erreur sur le fichier des routes + +Si le fichier des routes **"app/Config/routes.yaml"** est introuvable, le framework lève une exception `ErrorRouteConfigNotExistException` : + +````Bash +Fatal error: Uncaught Studoo\EduFramework\Core\Exception\ErrorRouteConfigNotExistException: +Le fichier de configuration des routes <.../app/config/routes.yaml> n'existe pas. +Vérifiez la configuration 'route_config_path' et la casse du dossier +(Exemple: app/Config et non app/config, les systèmes de fichiers Linux sont sensibles à la casse) +```` + +Les causes possibles : + +- la configuration `route_config_path` dans "public/index.php" ne pointe pas vers le bon dossier, +- **la casse du dossier est incorrecte** : le dossier doit s'appeler `app/Config` (C majuscule) et non `app/config`. + +!!! warning "Sensibilité à la casse" + + Sur macOS et Windows, la casse des noms de fichiers et de dossiers est ignorée : un projet avec `app/config` fonctionne en local. + En revanche, sur Linux (GitHub Codespaces, devcontainer, serveur de production), le système de fichiers est **sensible à la casse** : `app/config` et `app/Config` sont deux dossiers différents. + ### Erreur d'installation des dépendances Au moment de la commande "composer create-project studoo/edu-framework-skeleton ...", vous pouvez rencontrer des erreurs d'installation des dépendances. diff --git a/public/index.php b/public/index.php index aa3ae83..1822d36 100644 --- a/public/index.php +++ b/public/index.php @@ -33,6 +33,7 @@ 'base_path' => __DIR__ . '/../', 'twig_path' => __DIR__ . '/../app/Template', 'route_config_path' => __DIR__ . '/../app/Config/', + 'cache_path' => __DIR__ . '/../var/cache/', 'sqlite_path' => __DIR__ . '/../var/sqlite/', 'sqlite_logs_path' => __DIR__ . '/../var/logs/' ] diff --git a/public/upload/.gitkeep b/public/upload/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/EduFramework/Commands/ClearCacheCommand.php b/src/EduFramework/Commands/ClearCacheCommand.php new file mode 100644 index 0000000..ec3b991 --- /dev/null +++ b/src/EduFramework/Commands/ClearCacheCommand.php @@ -0,0 +1,67 @@ +writeln([ + CommandBanner::getBanner(), + 'Suppression du cache', + '' + ]); + + // Le chemin du cache est défini dans la configuration (clé cache_path) + $cachePath = ConfigCore::getConfig('cache_path'); + + if (is_dir($cachePath) === false) { + self::$stdOutput->info("Aucun cache à supprimer. Le dossier <$cachePath> n'existe pas"); + return Command::SUCCESS; + } + + // Suppression du contenu du dossier de cache Exemple: le cache TWIG (var/cache/twig) + // Le dossier de cache lui-même est conservé + $fileSystem = new Filesystem(); + foreach (scandir($cachePath) as $item) { + if ($item !== '.' && $item !== '..') { + $fileSystem->remove($cachePath . DIRECTORY_SEPARATOR . $item); + } + } + + self::$stdOutput->success("Le cache a été supprimé : " . realpath($cachePath)); + + return Command::SUCCESS; + } +} diff --git a/src/EduFramework/Commands/Extends/AppCommand.php b/src/EduFramework/Commands/Extends/AppCommand.php index 1d36432..1574c5d 100644 --- a/src/EduFramework/Commands/Extends/AppCommand.php +++ b/src/EduFramework/Commands/Extends/AppCommand.php @@ -47,6 +47,7 @@ public function __construct() $this->add(new \Studoo\EduFramework\Commands\StartCommand()); $this->add(new \Studoo\EduFramework\Commands\CreateCliCommand()); $this->add(new \Studoo\EduFramework\Commands\CreateApiCommand()); + $this->add(new \Studoo\EduFramework\Commands\ClearCacheCommand()); if (file_exists(ConfigCore::getConfig('command_config_path') . 'commands.yaml') === true) { $commandList = Yaml::parseFile(ConfigCore::getConfig('command_config_path') . 'commands.yaml'); diff --git a/src/EduFramework/Core/ConfigCore.php b/src/EduFramework/Core/ConfigCore.php index 990b607..797e7f7 100644 --- a/src/EduFramework/Core/ConfigCore.php +++ b/src/EduFramework/Core/ConfigCore.php @@ -37,13 +37,14 @@ public function __construct(array $config) self::$config = array_merge( [ 'name' => 'EduFramework', - 'version' => 'v2.3.2@stable', - 'date_version' => '2026-10-05', // Date de la livraison de la version + 'version' => 'v2.4.0@stable', + 'date_version' => '2026-10-08', // Date de la livraison de la version 'php_version' => '8.4', // Warning : bin/edu require PHP 8.4 or higher 'base_path' => '/', 'twig_path' => '/app/Template', 'route_config_path' => '/app/Config/', 'command_config_path' => 'app/Config/', + 'cache_path' => './var/cache/', 'sqlite_path' => './var/sqlite/', 'sqlite_logs_path' => './var/logs/', ], diff --git a/src/EduFramework/Core/Controller/BuildControllerTrait.php b/src/EduFramework/Core/Controller/BuildControllerTrait.php index cc85e88..156a9ec 100644 --- a/src/EduFramework/Core/Controller/BuildControllerTrait.php +++ b/src/EduFramework/Core/Controller/BuildControllerTrait.php @@ -32,4 +32,87 @@ public function buildController(string $controller): ControllerInterface // Création de l'objet du controller en respectant le contrat de l'interface "ControllerInterface" return new $controller(); } + + /** + * Sépare le nom de la classe du controller et le nom de la méthode à appeler + * Le handler peut être de deux formes : + * - "Controller\MedecinController" : la méthode execute() sera appelée par défaut + * - "Controller\MedecinController::index" : la méthode index() sera appelée + * @param string $handler Nom du controller suivi ou non de la méthode à appeler + * @return array Tableau contenant le nom de la classe et le nom de la méthode [0 => classe, 1 => méthode|null] + */ + public function resolveHandler(string $handler): array + { + // Pas de méthode explicite dans le handler, la méthode par défaut est execute() + if (strpos($handler, '::') === false) { + return [$handler, null]; + } + + // Séparation du nom de la classe et du nom de la méthode + [$controller, $method] = explode('::', $handler, 2); + + // Si aucune méthode n'est renseignée, on retombe sur la méthode par défaut execute() + if ($method === '') { + return [$controller, null]; + } + + return [$controller, $method]; + } + + /** + * Appelle la méthode du controller associée à la requête HTTP + * Cette méthode est utilisée quand la route définit une méthode explicite Exemple: Controller\MedecinController::index + * La méthode du controller doit être publique, non statique, + * avoir un paramètre de type Request et retourner une chaine de caractères (string) ou null + * @param Request $request La requête HTTP contenant le controller (getHander) et la méthode (getAction) + * @return string|null + * @throws ErrorControllerException + */ + public function callAction(Request $request): string|null + { + $controller = $request->getHander(); + $action = $request->getAction(); + + // On vérifie que le controller existe + if (class_exists($controller) === false) { + throw new ErrorControllerException("Le controller <$controller> n'existe pas"); + } + + // On vérifie que la méthode existe dans le controller + if (method_exists($controller, $action) === false) { + throw new ErrorControllerException("La méthode <$action> n'existe pas dans le controller <$controller>"); + } + + $reflection = new \ReflectionMethod($controller, $action); + + // On vérifie que la méthode est publique et non statique + if ($reflection->isPublic() === false || $reflection->isStatic() === true) { + throw new ErrorControllerException( + "La méthode <$action> du controller <$controller> doit être publique et non statique" + ); + } + + // On vérifie que la méthode a un paramètre de type Request + $parameters = $reflection->getParameters(); + if (count($parameters) === 0 + || $parameters[0]->getType() === null + || $parameters[0]->getType()->getName() !== Request::class) { + throw new ErrorControllerException( + "La méthode <$action> du controller <$controller> doit avoir un paramètre de type Request" + ); + } + + // On vérifie que le type de retour est compatible avec le framework : string ou null + $returnType = $reflection->getReturnType(); + if ($returnType !== null + && ($returnType instanceof \ReflectionNamedType === false + || in_array($returnType->getName(), ['string', 'mixed'], true) === false)) { + throw new ErrorControllerException( + "La méthode <$action> du controller <$controller> doit retourner une chaine de caractères (string) ou null" + ); + } + + // Appel de la méthode du controller avec la requête HTTP + return (new $controller())->$action($request); + } } diff --git a/src/EduFramework/Core/Controller/FastRouteCore.php b/src/EduFramework/Core/Controller/FastRouteCore.php index c201537..0243c6c 100644 --- a/src/EduFramework/Core/Controller/FastRouteCore.php +++ b/src/EduFramework/Core/Controller/FastRouteCore.php @@ -16,6 +16,7 @@ use Studoo\EduFramework\Core\Controller\Error\HttpError404Controller; use Studoo\EduFramework\Core\Controller\Error\HttpError405Controller; use Studoo\EduFramework\Core\Controller\Error\HttpErrorDefaultController; +use Studoo\EduFramework\Core\Exception\ErrorRouteConfigNotExistException; use Symfony\Component\Yaml\Yaml; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; @@ -51,9 +52,20 @@ public function __construct() * Methode pour charger les routes depuis un fichier de configuration (Config/route.yaml) * @param string $pathConfigFile Chemin vers le fichier de configuration * @return $this + * @throws ErrorRouteConfigNotExistException */ public function loadRouteConfig(string $pathConfigFile): self { + // On vérifie que le fichier de configuration des routes existe + // pour renvoyer une erreur explicite plutôt qu'une exception du composant Yaml + if (is_file($pathConfigFile . 'routes.yaml') === false) { + throw new ErrorRouteConfigNotExistException( + "Le fichier de configuration des routes <" . $pathConfigFile . "routes.yaml> n'existe pas. " + . "Vérifiez la configuration 'route_config_path' et la casse du dossier " + . "(Exemple: app/Config et non app/config, les systèmes de fichiers Linux sont sensibles à la casse)" + ); + } + $fileData = Yaml::parseFile($pathConfigFile . 'routes.yaml'); if (is_array($fileData)) { foreach ($fileData as $routeConfig) { @@ -70,7 +82,10 @@ public function loadRouteConfig(string $pathConfigFile): self * Cette route peut avoir des méthodes HTTP associées (GET, POST, PUT, DELETE, ...) * @param string|array $httpMethod (GET, POST, PUT, DELETE, ...) * @param string $uri La route à appeler - * @param string $controller Nom du controller à appeler + * @param string $controller Nom du controller à appeler. + * Deux formes possibles : + * - "Controller\MedecinController" : appel de la méthode execute() par défaut + * - "Controller\MedecinController::index" : appel de la méthode index() * @return $this */ public function addRoute(string|array $httpMethod, string $uri, string $controller): self @@ -92,6 +107,7 @@ public function getDispatcher(): Dispatcher /** * Methode pour récupérer la classe controller à appeler * Elle retourne le résultat de la méthode execute() du controller + * ou de la méthode définie dans la route Exemple: Controller\MedecinController::index * @return string|null * @throws \Twig\Error\LoaderError * @throws \Twig\Error\RuntimeError @@ -146,15 +162,32 @@ public function getRoute(): string|null break; // Si la route est trouvée alors j'appelle le controller correspondant case Dispatcher::FOUND: + // Le handler peut être de deux formes : + // - "Controller\MedecinController" : la méthode execute() sera appelée par défaut + // - "Controller\MedecinController::index" : la méthode index() sera appelée + [$controller, $action] = $this->resolveHandler($routeInfo[1]); + // J'ajoute le nom de la classe controller à appeler // et les paramètres de la route à l'objet requête HTTP - $request->setHander($routeInfo[1])->setVars($_GET); - $request->setHander($routeInfo[1])->setVars($_POST); - $request->setHander($routeInfo[1])->setVars($routeInfo[2]); - - // J'appelle la méthode execute() du controller - // et je récupère la vue à afficher - $returnView = $this->buildController($request->getHander())->execute($request); + $request->setHander($controller); + $request->setVars($_GET); + $request->setVars($_POST); + $request->setVars($routeInfo[2]); + // Les fichiers téléversés via un formulaire multipart/form-data + $request->setFiles($_FILES); + + if ($action === null) { + // Sans méthode explicite dans la route, le controller doit implémenter l'interface ControllerInterface + // J'appelle la méthode execute() du controller + // et je récupère la vue à afficher + $returnView = $this->buildController($request->getHander())->execute($request); + } else { + // Une méthode explicite est définie dans la route Exemple: Controller\MedecinController::index + // La validation de la méthode est faite dans la méthode callAction() + // J'appelle la méthode du controller et je récupère la vue à afficher + $request->setAction($action); + $returnView = $this->callAction($request); + } break; default: $returnView = (new HttpErrorDefaultController())->execute($request); diff --git a/src/EduFramework/Core/Controller/Request.php b/src/EduFramework/Core/Controller/Request.php index d30feff..175c884 100644 --- a/src/EduFramework/Core/Controller/Request.php +++ b/src/EduFramework/Core/Controller/Request.php @@ -38,12 +38,27 @@ class Request */ private string $hander = ''; + /** + * La méthode du controller à appeler + * Par défaut, c'est la méthode execute() du controller + * @var string $action + */ + private string $action = 'execute'; + /** * Les variables de la requête HTTP * @var array $vars */ private array $vars = []; + /** + * Les fichiers téléversés de la requête HTTP (structure normalisée) + * Chaque clé du formulaire pointe vers une liste de fichiers + * Exemple: $files['fichier'][0]['name'] + * @var array $files + */ + private array $files = []; + /** * Une requete HTTP a obligatoirement une route et une méthode HTTP (GET, POST ...) * Cet objet est présent dans la méthode execute() d'un controller @@ -107,6 +122,29 @@ public function setHander(string $hander): Request return $this; } + /** + * Renvoi la méthode du controller qui est associée à la requête HTTP + * Par défaut, c'est la méthode execute() du controller + * @return string + */ + public function getAction(): string + { + return $this->action; + } + + /** + * Permet de définir la méthode du controller à appeler + * La méthode est définie dans le fichier de configuration des routes (Config/routes.yaml) + * Exemple: Controller\MedecinController::index + * @param string $action Le nom de la méthode du controller + * @return Request + */ + public function setAction(string $action): Request + { + $this->action = $action; + return $this; + } + /** * Permet de récupérer une variable de la requête HTTP * @param string $key Le nom de la variable @@ -137,6 +175,150 @@ public function setVars(array $vars): Request return $this; } + /** + * Permet de définir les fichiers téléversés de la requête HTTP ($_FILES) + * La structure native de $_FILES est normalisée : + * chaque clé du formulaire devient une liste de fichiers, + * que le champ HTML soit simple () + * ou multiple () + * @param array $files Les fichiers téléversés (Exemple: $_FILES) + * @return Request + */ + public function setFiles(array $files): Request + { + foreach ($files as $key => $file) { + if (is_array($file['name'] ?? null) === true) { + // Champ multi-fichiers Exemple: + $listeFichiers = []; + foreach (array_keys($file['name']) as $index) { + $listeFichiers[] = [ + 'name' => $file['name'][$index], + 'type' => $file['type'][$index], + 'size' => $file['size'][$index], + 'tmp_name' => $file['tmp_name'][$index], + 'error' => $file['error'][$index], + ]; + } + $this->files[$key] = $listeFichiers; + } else { + // Champ simple + $this->files[$key] = [ + [ + 'name' => $file['name'], + 'type' => $file['type'], + 'size' => $file['size'], + 'tmp_name' => $file['tmp_name'], + 'error' => $file['error'], + ], + ]; + } + } + return $this; + } + + /** + * Renvoi l'ensemble des fichiers téléversés de la requête HTTP (structure normalisée) + * @return array + */ + public function getFiles(): array + { + return $this->files; + } + + /** + * Renvoi la liste des fichiers téléversés pour un champ du formulaire + * La structure native de $_FILES est normalisée : + * la méthode retourne toujours une liste de fichiers, même pour un champ simple + * @param string $key Le nom du champ du formulaire + * @return array|null La liste des fichiers ou null si le champ n'existe pas + */ + public function getFile(string $key): array|null + { + return $this->files[$key] ?? null; + } + + /** + * Indique si le formulaire a envoyé un fichier pour ce champ + * Attention : un champ laissé vide génère une entrée avec error = UPLOAD_ERR_NO_FILE (4) + * hasFile() retourne true mais isValid() retourne false + * @param string $key Le nom du champ du formulaire + * @return bool + */ + public function hasFile(string $key): bool + { + return array_key_exists($key, $this->files); + } + + /** + * Vérifie que le(s) fichier(s) téléversé(s) du champ sont valides (error = UPLOAD_ERR_OK) + * @param string $key Le nom du champ du formulaire + * @param int|null $index L'index du fichier à vérifier (champ multi-fichiers), + * null pour vérifier tous les fichiers du champ + * @return bool + */ + public function isValid(string $key, int|null $index = null): bool + { + $fichiers = $this->getFile($key); + + if ($fichiers === null) { + return false; + } + + if ($index === null) { + foreach ($fichiers as $fichier) { + if ($fichier['error'] !== UPLOAD_ERR_OK) { + return false; + } + } + return true; + } + + return ($fichiers[$index]['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK; + } + + /** + * Renvoi l'extension du fichier téléversé (en minuscules) + * @param string $key Le nom du champ du formulaire + * @param int $index L'index du fichier (champ multi-fichiers) + * @return string|null L'extension (Exemple: csv) ou null + */ + public function getExtension(string $key, int $index = 0): string|null + { + $fichier = $this->getFile($key)[$index] ?? null; + + if ($fichier === null || $fichier['error'] !== UPLOAD_ERR_OK) { + return null; + } + + $extension = pathinfo($fichier['name'], PATHINFO_EXTENSION); + + return ($extension !== '' ? strtolower($extension) : null); + } + + /** + * Déplace un fichier téléversé vers sa destination finale + * Cette méthode encapsule la fonction PHP move_uploaded_file() + * en vérifiant au préalable que le téléversement est valide + * @param string $key Le nom du champ du formulaire + * @param string $destination Le chemin complet du fichier de destination + * @param int $index L'index du fichier (champ multi-fichiers) + * @return bool true si le fichier a été déplacé, false sinon + */ + public function move(string $key, string $destination, int $index = 0): bool + { + $fichier = $this->getFile($key)[$index] ?? null; + + if ($fichier === null || $fichier['error'] !== UPLOAD_ERR_OK) { + return false; + } + + if (is_uploaded_file($fichier['tmp_name']) === false) { + return false; + } + + return move_uploaded_file($fichier['tmp_name'], $destination); + } + /** * Renvoi la méthode HTTP de la requête * @return string diff --git a/src/EduFramework/Core/Controller/Route.php b/src/EduFramework/Core/Controller/Route.php index 320e29a..0ae8f1c 100644 --- a/src/EduFramework/Core/Controller/Route.php +++ b/src/EduFramework/Core/Controller/Route.php @@ -14,6 +14,7 @@ use FastRoute\RouteParser\Std; use Studoo\EduFramework\Core\ConfigCore; use Studoo\EduFramework\Core\Exception\BadRouteException; +use Studoo\EduFramework\Core\Exception\ErrorRouteConfigNotExistException; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -76,11 +77,24 @@ public function getRouteInfo(): array /** * Récupération du fichier config/routes.yaml - * @return array|bool Tableau des routes ou false si le fichier n'existe pas + * @return array|bool Tableau des routes ou false si le fichier ne contient pas de routes + * @throws ErrorRouteConfigNotExistException */ public function loadRoute(): array|bool { - $fileData = Yaml::parseFile(ConfigCore::getConfig("route_config_path") . 'routes.yaml'); + $pathFile = ConfigCore::getConfig("route_config_path") . 'routes.yaml'; + + // On vérifie que le fichier de configuration des routes existe + // pour renvoyer une erreur explicite plutôt qu'une exception du composant Yaml + if (is_file($pathFile) === false) { + throw new ErrorRouteConfigNotExistException( + "Le fichier de configuration des routes <" . $pathFile . "> n'existe pas. " + . "Vérifiez la configuration 'route_config_path' et la casse du dossier " + . "(Exemple: app/Config et non app/config, les systèmes de fichiers Linux sont sensibles à la casse)" + ); + } + + $fileData = Yaml::parseFile($pathFile); if (is_array($fileData)) { return $fileData; } diff --git a/src/EduFramework/Core/Exception/ErrorRouteConfigNotExistException.php b/src/EduFramework/Core/Exception/ErrorRouteConfigNotExistException.php new file mode 100644 index 0000000..44a2b07 --- /dev/null +++ b/src/EduFramework/Core/Exception/ErrorRouteConfigNotExistException.php @@ -0,0 +1,27 @@ + false, 'debug' => true] + [ + 'cache' => ConfigCore::getConfig('cache_path') . 'twig', + 'auto_reload' => true, + 'debug' => true, + ] ); // Ajoutez l'extension Debug self::$twig->addExtension(new DebugExtension()); diff --git a/tests/Command/ClearCacheCommandTest.php b/tests/Command/ClearCacheCommandTest.php new file mode 100644 index 0000000..5b63a2e --- /dev/null +++ b/tests/Command/ClearCacheCommandTest.php @@ -0,0 +1,76 @@ +cachePath = sys_get_temp_dir() . '/edu-framework-test-cache-' . uniqid() . '/'; + + // Simulation du contenu d'un dossier de cache (Exemple: le cache TWIG) + mkdir($this->cachePath . 'twig', 0777, true); + file_put_contents($this->cachePath . 'twig/template.html.php', 'cache compile'); + file_put_contents($this->cachePath . 'fichier-test.txt', 'cache'); + + (new ConfigCore(['cache_path' => $this->cachePath])); + $application = new Application(ConfigCore::getConfig('name'), ConfigCore::getConfig('version')); + $application->add(new ClearCacheCommand()); + $command = $application->find('cache:clear'); + $this->commandeTester = new CommandTester($command); + } + + protected function tearDown(): void + { + // Nettoyage du dossier de cache temporaire + (new Filesystem())->remove($this->cachePath); + $this->commandeTester = null; + } + + public function testCommandClearCache(): void + { + $this->commandeTester->execute([]); + $output = $this->commandeTester->getDisplay(); + + $this->assertStringContainsString('Suppression du cache', $output); + $this->assertStringContainsString('Le cache a été supprimé', $output); + + // Le contenu du dossier de cache est supprimé + $this->assertFileDoesNotExist($this->cachePath . 'fichier-test.txt'); + $this->assertFileDoesNotExist($this->cachePath . 'twig/template.html.php'); + + // Le dossier de cache lui-même est conservé + $this->assertDirectoryExists($this->cachePath); + } + + public function testCommandClearCacheWithDirNotExist(): void + { + // Le dossier de cache n'existe pas + (new ConfigCore(['cache_path' => sys_get_temp_dir() . '/edu-framework-test-cache-inexistant-' . uniqid() . '/'])); + + $application = new Application(ConfigCore::getConfig('name'), ConfigCore::getConfig('version')); + $application->add(new ClearCacheCommand()); + $command = $application->find('cache:clear'); + $commandeTester = new CommandTester($command); + + $commandeTester->execute([]); + $output = $commandeTester->getDisplay(); + + $this->assertStringContainsString('Aucun cache à supprimer', $output); + } +} diff --git a/tests/Config/routes.yaml b/tests/Config/routes.yaml index 6714cb9..bae223a 100644 --- a/tests/Config/routes.yaml +++ b/tests/Config/routes.yaml @@ -21,4 +21,16 @@ userUpdate: userName: uri: /user/{id:\d+}[/{name}] controller: Controller\UserController + httpMethod: [GET,POST] +medecin: + uri: /medecin + controller: Controller\MedecinController::index + httpMethod: [GET] +medecin_new: + uri: /medecin/new + controller: Controller\MedecinController::new + httpMethod: [GET,POST] +medecin_import: + uri: /medecin/import + controller: Controller\MedecinController::import httpMethod: [GET,POST] \ No newline at end of file diff --git a/tests/Controller/FastRouteCoreTest.php b/tests/Controller/FastRouteCoreTest.php index 16df16b..d426b8f 100644 --- a/tests/Controller/FastRouteCoreTest.php +++ b/tests/Controller/FastRouteCoreTest.php @@ -6,6 +6,8 @@ use PHPUnit\Framework\TestCase; use Studoo\EduFramework\Core\ConfigCore; use Studoo\EduFramework\Core\Controller\FastRouteCore; +use Studoo\EduFramework\Core\Exception\ErrorControllerException; +use Studoo\EduFramework\Core\Exception\ErrorRouteConfigNotExistException; use Studoo\EduFramework\Core\View\TwigCore; class FastRouteCoreTest extends TestCase @@ -18,6 +20,9 @@ public function setUp(): void // de la barre de debug dans le rendu et casserait les hashs ci-dessous. unset($_ENV['APP_ENV'], $_SERVER['APP_ENV']); + // Neutralise les fichiers téléversés pour isoler chaque test + $_FILES = []; + (new ConfigCore([ 'twig_path' => __DIR__ . '/../../app/Template', 'route_config_path' => __DIR__ . "/../Config/" @@ -77,6 +82,14 @@ public function testLoadRoutesMatchSuccess() $this->assertEquals('a2bd992a139c4ec5c9a3cac42e646dfc4a8e026c719d37924f193e7ff06fe520', hash('sha256', $route->getRoute())); } + public function testLoadRouteConfigWithFileNotExist() + { + $route = new FastRouteCore(); + + $this->expectException(ErrorRouteConfigNotExistException::class); + $route->loadRouteConfig(__DIR__ . '/../ConfigNotExist/'); + } + public function testLoadRoutesMatchSuccessWithExceptionNotFound() { $route = new FastRouteCore(); @@ -118,4 +131,85 @@ public function testLinkToGetnametopathInTwigUserid() $this->assertEquals('ab2550a7609a2997e9aaef4b49a12388d8978d5507d970cb45fa96fec99e768b', hash('sha256', $route->getRoute())); } + + public function testRouteWithMethodExplicitIndex() + { + $route = new FastRouteCore(); + $route->loadRouteConfig(__DIR__ . '/../Config/'); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/medecin'; + + $this->assertEquals('ed13c9c6c5ed3833bd1af6bea040089eff6e48baefcc219a9a04f382f2532931', hash('sha256', $route->getRoute())); + } + + public function testRouteWithMethodExplicitNew() + { + $route = new FastRouteCore(); + $route->loadRouteConfig(__DIR__ . '/../Config/'); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/medecin/new'; + + $this->assertEquals('e2f7b9a3c530c751e475fca16a4e46e95e249ceca802a947adc17e50bfb96a91', hash('sha256', $route->getRoute())); + } + + public function testRouteWithMethodNotExist() + { + $route = new FastRouteCore(); + $route->addRoute('GET', '/medecin/error', "Controller\MedecinController::inconnue"); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/medecin/error'; + + $this->expectException(ErrorControllerException::class); + $this->expectExceptionMessage("La méthode n'existe pas dans le controller "); + $route->getRoute(); + } + + public function testRouteWithMethodNotPublic() + { + $route = new FastRouteCore(); + $route->addRoute('GET', '/medecin/error', "Controller\MedecinController::testPrivate"); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/medecin/error'; + + $this->expectException(ErrorControllerException::class); + $route->getRoute(); + } + + public function testRouteImportWithFileInvalid() + { + $route = new FastRouteCore(); + $route->loadRouteConfig(__DIR__ . '/../Config/'); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/medecin/import'; + $_FILES = [ + 'fichier' => [ + 'name' => 'medecins.csv', + 'type' => 'text/csv', + 'size' => 123, + 'tmp_name' => '/tmp/phpXYZ', + 'error' => UPLOAD_ERR_INI_SIZE, + ], + ]; + + // Le fichier téléversé est en erreur : la vue affiche la branche "fichier invalide" + $this->assertEquals('c3a41ed55dbc9d7f5798e52b5140f32adb704b37859824845ad92b82c54d9794', hash('sha256', $route->getRoute())); + } + + public function testRouteImportWithoutFile() + { + $route = new FastRouteCore(); + $route->loadRouteConfig(__DIR__ . '/../Config/'); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/medecin/import'; + $_FILES = []; + + // Aucun fichier envoyé : la vue affiche la branche "aucun fichier" + $this->assertEquals('b619f84de911fb72a6459e15946fbe934a00a63fcf3a7d6d23c9bd0506c3137a', hash('sha256', $route->getRoute())); + } } diff --git a/tests/Controller/RequestTest.php b/tests/Controller/RequestTest.php index e2d6562..b65fdfd 100644 --- a/tests/Controller/RequestTest.php +++ b/tests/Controller/RequestTest.php @@ -66,4 +66,166 @@ public function testGetHander() $request->setHander("Controller\HomeController"); $this->assertEquals("Controller\HomeController", $request->getHander()); } + + public function testGetActionDefault() + { + $request = new Request("/test", "GET"); + $this->assertEquals("execute", $request->getAction()); + } + + public function testSetAction() + { + $request = new Request("/test", "GET"); + $request->setAction("index"); + $this->assertEquals("index", $request->getAction()); + } + + public function testSetActionAndHander() + { + $request = new Request("/test", "GET"); + $request->setHander("Controller\MedecinController")->setAction("index"); + $this->assertEquals("Controller\MedecinController", $request->getHander()); + $this->assertEquals("index", $request->getAction()); + } + + public function testSetFilesSimpleField() + { + $request = new Request("/test", "POST"); + $request->setFiles([ + 'fichier' => [ + 'name' => 'medecins.csv', + 'type' => 'text/csv', + 'size' => 123, + 'tmp_name' => '/tmp/phpXYZ', + 'error' => UPLOAD_ERR_OK, + ], + ]); + + // La structure est normalisée : une liste de fichiers même pour un champ simple + $this->assertEquals( + [[ + 'name' => 'medecins.csv', + 'type' => 'text/csv', + 'size' => 123, + 'tmp_name' => '/tmp/phpXYZ', + 'error' => UPLOAD_ERR_OK, + ]], + $request->getFile('fichier') + ); + $this->assertNull($request->getFile('inconnu')); + $this->assertTrue($request->hasFile('fichier')); + $this->assertFalse($request->hasFile('inconnu')); + $this->assertEquals(1, count($request->getFiles())); + } + + public function testSetFilesMultipleField() + { + $request = new Request("/test", "POST"); + $request->setFiles([ + 'galerie' => [ + 'name' => ['a.png', 'b.png'], + 'type' => ['image/png', 'image/png'], + 'size' => [123, 456], + 'tmp_name' => ['/tmp/phpA', '/tmp/phpB'], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_OK], + ], + ]); + + // La structure native de $_FILES en multi-upload est normalisée en liste de fichiers + $this->assertEquals( + [ + ['name' => 'a.png', 'type' => 'image/png', 'size' => 123, 'tmp_name' => '/tmp/phpA', 'error' => UPLOAD_ERR_OK], + ['name' => 'b.png', 'type' => 'image/png', 'size' => 456, 'tmp_name' => '/tmp/phpB', 'error' => UPLOAD_ERR_OK], + ], + $request->getFile('galerie') + ); + } + + public function testIsValid() + { + $request = new Request("/test", "POST"); + $request->setFiles([ + 'fichier' => [ + 'name' => 'medecins.csv', + 'type' => 'text/csv', + 'size' => 123, + 'tmp_name' => '/tmp/phpXYZ', + 'error' => UPLOAD_ERR_OK, + ], + ]); + $this->assertTrue($request->isValid('fichier')); + $this->assertTrue($request->isValid('fichier', 0)); + $this->assertFalse($request->isValid('inconnu')); + $this->assertFalse($request->isValid('fichier', 10)); + } + + public function testIsValidWithError() + { + $request = new Request("/test", "POST"); + $request->setFiles([ + 'fichier' => [ + 'name' => '', + 'type' => '', + 'size' => 0, + 'tmp_name' => '', + 'error' => UPLOAD_ERR_NO_FILE, + ], + ]); + // Champ laissé vide : hasFile() vrai mais isValid() faux + $this->assertTrue($request->hasFile('fichier')); + $this->assertFalse($request->isValid('fichier')); + } + + public function testGetExtension() + { + $request = new Request("/test", "POST"); + $request->setFiles([ + 'fichier' => [ + 'name' => 'medecins.CSV', + 'type' => 'text/csv', + 'size' => 123, + 'tmp_name' => '/tmp/phpXYZ', + 'error' => UPLOAD_ERR_OK, + ], + ]); + $this->assertEquals('csv', $request->getExtension('fichier')); + $this->assertNull($request->getExtension('inconnu')); + } + + public function testMoveWithFieldNotExist() + { + $request = new Request("/test", "POST"); + $this->assertFalse($request->move('inconnu', '/tmp/destination.csv')); + } + + public function testMoveWithError() + { + $request = new Request("/test", "POST"); + $request->setFiles([ + 'fichier' => [ + 'name' => 'medecins.csv', + 'type' => 'text/csv', + 'size' => 123, + 'tmp_name' => '/tmp/phpXYZ', + 'error' => UPLOAD_ERR_INI_SIZE, + ], + ]); + $this->assertFalse($request->move('fichier', '/tmp/destination.csv')); + } + + public function testMoveWithNotUploadedFile() + { + $request = new Request("/test", "POST"); + $request->setFiles([ + 'fichier' => [ + 'name' => 'medecins.csv', + 'type' => 'text/csv', + 'size' => 123, + 'tmp_name' => '/tmp/fichier-factice.csv', + 'error' => UPLOAD_ERR_OK, + ], + ]); + // Sans vrai téléversement, is_uploaded_file() échoue et move() retourne false + $this->assertFalse($request->move('fichier', '/tmp/destination.csv')); + } } diff --git a/tests/Controller/RouteTest.php b/tests/Controller/RouteTest.php index adcd775..ac306df 100644 --- a/tests/Controller/RouteTest.php +++ b/tests/Controller/RouteTest.php @@ -3,6 +3,7 @@ namespace Controller; use Studoo\EduFramework\Core\Exception\BadRouteException; +use Studoo\EduFramework\Core\Exception\ErrorRouteConfigNotExistException; use PHPUnit\Framework\TestCase; use Studoo\EduFramework\Core\ConfigCore; use Studoo\EduFramework\Core\Controller\Route; @@ -89,4 +90,17 @@ public function testGetListRouteNameForException() $this->expectException(BadRouteException::class); $this->assertEquals('/user/1', $route->getNameToPath('userNameError', ['id' => 2])); } + + public function testLoadRouteWithFileNotExist() + { + (new ConfigCore([ + 'twig_path' => __DIR__ . '/../../app/Template', + 'route_config_path' => __DIR__ . "/../ConfigNotExist/" + ])); + + $route = new Route(); + + $this->expectException(ErrorRouteConfigNotExistException::class); + $route->getRouteInfo(); + } } \ No newline at end of file