diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9aa691..ceb7f57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,10 +37,10 @@ Les tests sont dans `tests/`, organisés en miroir de `src/`. | Fichier | Classe testée | Ce qui est vérifié | |--------------------------------|---------------------------|---------------------| -| `AuthServiceTest` | `AuthApplicationService` | `authenticate()`, `changePassword()`, `login/logout/isLoggedIn()` | -| `AuthServiceRateLimitTest` | `AuthApplicationService` | `checkRateLimit()` (IP libre, verrouillée, expirée, minimum 1 minute, `deleteExpired()`), `recordFailure()` (constantes MAX_ATTEMPTS/LOCK_MINUTES), `resetRateLimit()` | -| `LoginAttemptRepositoryTest` | `PdoLoginAttemptRepository` | `findByIp()`, `recordFailure()` (INSERT vs UPDATE, compteur, seuil exact, fenêtre temporelle), `resetForIp()`, `deleteExpired()` | -| `PasswordResetServiceTest` | `PasswordResetApplicationService` | `requestReset()` (email inconnu silencieux, invalidation, création, envoi, URL), `validateToken()` (inexistant, expiré, valide), `resetPassword()` (token invalide, mdp trop court via `WeakPasswordException`, mise à jour + consommation) | +| `AuthServiceTest` | `AuthApplicationService` | `createUser()` (normalisation, unicité via exceptions métier, longueur mdp), `authenticate()`, `changePassword()`, `login/logout/isLoggedIn()` | +| `AuthServiceRateLimitTest` | `AuthApplicationService` | `checkRateLimit()` (IP libre, verrouillée, expirée, minimum 1 minute, `deleteExpired()`), `recordFailure()` (constantes MAX_ATTEMPTS/LOCK_MINUTES), `resetRateLimit()` | +| `LoginAttemptRepositoryTest` | `PdoLoginAttemptRepository` | `findByIp()`, `recordFailure()` (INSERT vs UPDATE, compteur, seuil exact, fenêtre temporelle), `resetForIp()`, `deleteExpired()` | +| `PasswordResetServiceTest` | `PasswordResetApplicationService` | `requestReset()` (email inconnu silencieux, invalidation, création, envoi, URL), `validateToken()` (inexistant, expiré, valide), `resetPassword()` (token invalide, mdp trop court via `WeakPasswordException`, mise à jour + consommation) | | `PasswordResetRepositoryTest` | `PdoPasswordResetRepository` | `create()`, `findActiveByHash()` (filtre `used_at = null`), `invalidateByUserId()` et `markAsUsed()` (jamais de `delete`) | ### `tests/User/` @@ -73,7 +73,8 @@ $repo = $this->createMock(UserRepositoryInterface::class); $repo = $this->createMock(UserRepository::class); ``` -Les tests de repositories (`UserRepositoryTest`, etc.) testent l'implémentation concrète avec un mock PDO — c'est intentionnel. +Les tests de repositories (`PdoUserRepository`, etc.) testent l'implémentation concrète avec un mock PDO — c'est intentionnel. +Ils doivent vérifier l'intention générale des requêtes et les valeurs retournées, sans figer inutilement chaque détail interne (noms exacts de placeholders, méthode PDO précise utilisée quand cela n'apporte rien, etc.). ### Exceptions métier diff --git a/README.md b/README.md index 549eaa1..2990293 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Tests](https://img.shields.io/badge/tests-442%20passing-brightgreen) ![Licence](https://img.shields.io/badge/licence-MIT-green) -Blog multi-utilisateurs modulaire développé avec Slim 4. Les domaines `Auth`, `Post`, `Category`, `Media`, `User` +Blog multi-utilisateurs modulaire développé avec Slim 4. Les domaines `Auth`, `Category`, `Media`, `User` et `Shared` portent une architecture DDD légère, lisible et réutilisable pour d'autres projets (boutique, portfolio…). @@ -181,9 +181,9 @@ Le contenu du blog (articles publiés) est soumis à [CC BY-SA 4.0](https://crea Le provisionnement (migrations + seed admin) s'exécute explicitement via `php bin/provision.php`. -- Développement local : exécuter `php bin/provision.php` après `cp .env.example .env` -- Docker / production : exécuter `docker compose exec app php bin/provision.php` après le demarrage du conteneur +- Développement local : exécuter `php bin/provision.php` apres `cp .env.example .env` +- Docker / production : exécuter `docker compose exec app php bin/provision.php` apres le demarrage du conteneur Le runtime HTTP ne provisionne plus automatiquement la base. Si le schéma n'est pas présent, l'application echoue avec un message explicite demandant d'exécuter la commande de provisionnement. -Pour repartir d'un schéma frais en développement après un nettoyage de l'historique des migrations, supprimez d'abord la base SQLite locale puis relancez le provisionnement : `rm -f database/app.sqlite` (ou votre fichier SQLite configuré), puis `php bin/provision.php`. +Pour repartir d'un schéma frais en développement apres un nettoyage de l'historique des migrations, supprimez d'abord la base SQLite locale puis relancez le provisionnement : `rm -f database/app.sqlite` (ou votre fichier SQLite configure), puis `php bin/provision.php`. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index f64cfee..6139f78 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -167,7 +167,7 @@ $id = 42; ?int $authorId = null; // Signature typée complète — paramètres et valeur de retour -// Extrait de PostApplicationService +// Extrait de PostService public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int ``` @@ -206,7 +206,7 @@ foreach ($scores as $nom => $points) { } ``` -La boucle `while` fonctionne comme dans tous les langages. Le projet l'utilise notamment dans `PostApplicationService::generateUniqueSlug()` pour tester des variantes jusqu'à trouver un slug libre : +La boucle `while` fonctionne comme dans tous les langages. Le projet l'utilise notamment dans `PostService::generateUniqueSlug()` pour tester des variantes jusqu'à trouver un slug libre : ```php $slug = $baseSlug; @@ -310,7 +310,7 @@ final class NotFoundException extends \RuntimeException } } -// Utilisation dans PostApplicationService +// Utilisation dans PostService $post = $this->postRepository->findById($id); if ($post === null) { throw new NotFoundException('Article', $id); // → HTTP 404 @@ -398,26 +398,26 @@ final class PostRepository implements PostRepositoryInterface Dans Slim Blog, chaque repository a son interface. Cela apporte deux bénéfices concrets : - **Tests unitaires** — dans les tests, on remplace le repository réel par un mock qui ne touche pas la base de données. Le service est testé en isolation complète. -- **Interchangeabilité** — passer de SQLite à PostgreSQL ne nécessite qu'une nouvelle implémentation de l'interface. `PostApplicationService` ne change pas d'une ligne. +- **Interchangeabilité** — passer de SQLite à PostgreSQL ne nécessite qu'une nouvelle implémentation de l'interface. `PostService` ne change pas d'une ligne. -`PostApplicationService` déclare `PostRepositoryInterface $postRepository` dans son constructeur, jamais `PostRepository` directement. Dépendre des abstractions plutôt que des implémentations rend le code testable et évolutif. +`PostService` déclare `PostRepositoryInterface $postRepository` dans son constructeur, jamais `PostRepository` directement. Dépendre des abstractions plutôt que des implémentations rend le code testable et évolutif. #### 2.2.3 Injection de dépendances Plutôt que de créer ses dépendances soi-même, une classe les reçoit via son constructeur. C'est l'injection de dépendances (Dependency Injection). ```php -// ❌ Couplage fort — un service applicatif crée lui-même ses dépendances +// ❌ Couplage fort — PostService crée lui-même ses dépendances // Impossible à tester : on ne peut pas substituer un faux repository. -class PostApplicationService { +class PostService { public function __construct() { $this->repo = new PostRepository(); } } // ✅ Injection — les dépendances sont fournies de l'extérieur -// Extrait réel de src/Post/Application/PostApplicationService.php -final class PostApplicationService +// Extrait réel de src/Post/PostService.php +final class PostService { public function __construct( private readonly PostRepositoryInterface $postRepository, @@ -431,7 +431,7 @@ Qui assemble les dépendances ? PHP-DI dans `config/container.php`. Il résout a ```php // Extrait de config/container.php // Binding interface → implémentation : PHP-DI injecte PostRepository partout où -// PostRepositoryInterface est demandé. PostApplicationService lui-même est résolu par autowiring. +// PostRepositoryInterface est demandé. PostService lui-même est résolu par autowiring. PostRepositoryInterface::class => autowire(PdoPostRepository::class), PostServiceInterface::class => autowire(PostApplicationService::class), ``` @@ -579,7 +579,7 @@ database/ ← migrations SQL, une par table ├── 003_create_posts.php ├── 004_create_media.php ├── 005_create_password_resets.php - ├── 002_create_posts_search.php + ├── 006_create_posts_fts.php └── 007_create_login_attempts.php ``` @@ -778,12 +778,12 @@ Le domaine `Post/` illustre l'anatomie complète. | Post.php | Modèle immuable. Représente un article avec ses données. | | PostRepositoryInterface.php | Contrat : liste les méthodes de persistance sans les implémenter. | | PostRepository.php | Implémentation PDO : requêtes SQL réelles. | -| PostApplicationService.php | Logique métier : création d'un slug, validation, appel du repository. | +| PostService.php | Logique métier : création d'un slug, validation, appel du repository. | | PostController.php | Actions HTTP : reçoit une requête, appelle le service, renvoie une réponse. | | PostExtension.php | Extension Twig du domaine Post. Expose `post_excerpt`, `post_url`, `post_thumbnail`, `post_initials` dans les templates. | | RssController.php | Contrôleur dédié au flux RSS 2.0 (/rss.xml), distinct du PostController. | -Ce qui ne se voit pas dans ce tableau, c'est la direction des dépendances : `PostController` connaît `PostApplicationService` via `PostServiceInterface`, et le service ne connaît que `PostRepositoryInterface` — jamais `PdoPostRepository` directement. Le repository, lui, ne connaît rien d'autre que PDO. +Ce qui ne se voit pas dans ce tableau, c'est la direction des dépendances : `PostController` connaît `PostService`, mais `PostService` ne connaît que `PostRepositoryInterface` — jamais `PostRepository` directement. `PostRepository`, lui, ne connaît rien d'autre que PDO. ``` PostController @@ -795,7 +795,7 @@ PostRepositoryInterface ← implémente — PostRepository PDO (SQLite) ``` -> 💡 Règle : chaque couche ne connaît que la couche immédiatement en dessous, via son interface. `PostController` dépend de `PostServiceInterface` ; `PostApplicationService` dépend de `PostRepositoryInterface` : c'est à ce niveau que l'isolation est garantie, ce qui rend les tests unitaires possibles sans base de données. +> 💡 Règle : chaque couche ne connaît que la couche immédiatement en dessous, via son interface. `PostController` dépend de `PostServiceInterface` ; `PostService` dépend de `PostRepositoryInterface` : c'est à ce niveau que l'isolation est garantie, ce qui rend les tests unitaires possibles sans base de données. ### 5.4 Le flux d'une requête @@ -818,7 +818,7 @@ Routeur Slim PostController::create() │ extrait et valide les données POST ▼ -PostApplicationService::createPost() +PostService::createPost() │ sanitise le HTML (HTMLPurifier) │ génère un slug unique ▼ @@ -901,9 +901,9 @@ Il contient treize fichiers PHP dans `src/Auth/` : ``` -- Services -- -AuthApplicationService.php — connexion, sessions, vérification des rôles +AuthService.php — connexion, sessions, vérification des rôles AuthServiceInterface.php — contrat du service d'authentification -PasswordResetApplicationService.php — génération du token, envoi e-mail, validation +PasswordResetService.php — génération du token, envoi e-mail, validation PasswordResetRepositoryInterface.php PasswordResetRepository.php — persistance des tokens de réinitialisation LoginAttemptRepositoryInterface.php @@ -948,7 +948,7 @@ $this->authService->resetRateLimit($ip); $this->authService->login($user); // écrit userId/username/role en session ``` -> 💡 `AuthApplicationService::authenticate()` ne gère pas le rate limiting — c'est `AuthController` qui en est responsable. Cette séparation permet de tester chaque comportement indépendamment. +> 💡 `AuthService::authenticate()` ne gère pas le rate limiting — c'est `AuthController` qui en est responsable. Cette séparation permet de tester chaque comportement indépendamment. > > ⚠️ L'IP lue depuis `REMOTE_ADDR` derrière un proxy retourne l'IP interne du proxy — le rate-limit se verrouillerait alors pour tous les utilisateurs simultanément. Le projet centralise désormais cette logique dans `ClientIpResolver` / `RequestContext` et ne fait confiance aux en-têtes `X-Forwarded-*` que pour les proxies explicitement approuvés via `TRUSTED_PROXIES`. @@ -962,11 +962,11 @@ Chaque tentative est enregistrée systématiquement, qu'un email existe ou non. `Migrator::run()` appelle `syncFtsIndex()` à chaque démarrage. Cette méthode insère dans `posts_fts` les articles dont le `rowid` est absent de l'index — sans toucher aux entrées existantes (idempotent). -Ce mécanisme est nécessaire car les triggers FTS5 ne couvrent que les opérations effectuées **après** leur création. Les articles présents en base au moment de la migration 002 ne sont pas indexés rétroactivement par les triggers. Sans cette synchronisation, la recherche retourne zéro résultat sur une base existante. +Ce mécanisme est nécessaire car les triggers FTS5 ne couvrent que les opérations effectuées **après** leur création. Les articles présents en base au moment de la migration 006 ne sont pas indexés rétroactivement par les triggers. Sans cette synchronisation, la recherche retourne zéro résultat sur une base existante. `strip_tags()` est disponible dans ce contexte car `sqliteCreateFunction()` est appelé dans `container.php` avant `Migrator::run()` dans la séquence de démarrage. -`PasswordResetApplicationService` gère le cycle complet en trois étapes : +`PasswordResetService` gère le cycle complet en trois étapes : - `requestReset($email)` — cherche l'utilisateur, génère un token aléatoire, stocke son hash SHA-256 en base (jamais le token brut), envoie le lien par e-mail. Si l'e-mail est inconnu, retour silencieux pour ne pas révéler l'existence du compte. - `validateToken($tokenRaw)` — calcule le hash du token reçu, vérifie qu'il existe en base et n'est pas expiré (1 heure). Retourne l'utilisateur associé ou `null`. @@ -1118,9 +1118,9 @@ Table de protection anti-brute-force : stocke les tentatives de connexion échou ### 6.2 Migrations -Les migrations sont des fichiers PHP dans `database/migrations/`. Elles sont exécutées explicitement par `php bin/provision.php` via `Provisioner::run()`, qui appelle `Migrator::run()` puis `Seeder::seed()`. Chaque migration ne s'exécute qu'une fois (une table `migrations` trace l'historique). +Les migrations sont des fichiers PHP dans `database/migrations/`. Elles s'exécutent automatiquement au démarrage via `Migrator::run()`. Chaque migration ne s'exécute qu'une fois (une table `migrations` trace l'historique). -Le provisionnement des données initiales (compte admin) est géré séparément par `Seeder::seed()`, appelé après `Migrator::run()` dans `Provisioner::run()`. Cette séparation garantit que `Migrator` ne contient que du DDL, et `Seeder` que des données. +Le provisionnement des données initiales (compte admin) est géré séparément par `Seeder::seed()`, appelé après `Migrator::run()` dans la séquence de démarrage. Cette séparation garantit que `Migrator` ne contient que du DDL, et `Seeder` que des données. > 💡 Pour ajouter une colonne ou une table, créer un nouveau fichier de migration. Ne jamais modifier une migration déjà exécutée en production. @@ -1311,7 +1311,7 @@ Les logs Monolog de l'application sont dans `data/var/logs/`. #### Une migration ne s'exécute pas -Le Migrator trace les migrations déjà appliquées dans une table `migrations`. Si une migration a été modifiée après avoir été exécutée, le Migrator ne la réexécute pas. Pour repartir d'un schéma propre en développement, supprimer le fichier SQLite (`database/app.sqlite` en local, `data/database/app.sqlite` en Docker) puis relancer `php bin/provision.php`. +Le Migrator trace les migrations déjà appliquées dans une table `migrations`. Si une migration a été modifiée après avoir été exécutée, le Migrator ne la réexécute pas. Pour forcer une réexécution en développement, supprimer le fichier SQLite (`data/database/app.sqlite`) : les migrations repartent de zéro au prochain démarrage. > ⚠️ Ne jamais supprimer la base en production. Créer une nouvelle migration à la place. diff --git a/src/Auth/Application/AuthApplicationService.php b/src/Auth/Application/AuthApplicationService.php index fdf259e..9954d02 100644 --- a/src/Auth/Application/AuthApplicationService.php +++ b/src/Auth/Application/AuthApplicationService.php @@ -12,12 +12,6 @@ use App\User\Exception\WeakPasswordException; use App\User\User; use App\User\UserRepositoryInterface; -/** - * Service applicatif du domaine Auth. - * - * Regroupe l'authentification, le changement de mot de passe et le rate-limit - * des tentatives de connexion par adresse IP. - */ class AuthApplicationService implements AuthServiceInterface { private readonly LoginRateLimitPolicy $rateLimitPolicy; diff --git a/src/Auth/Application/PasswordResetApplicationService.php b/src/Auth/Application/PasswordResetApplicationService.php index 60b901e..e5a51f4 100644 --- a/src/Auth/Application/PasswordResetApplicationService.php +++ b/src/Auth/Application/PasswordResetApplicationService.php @@ -13,12 +13,6 @@ use App\User\User; use App\User\UserRepositoryInterface; use PDO; -/** - * Service applicatif du flux de réinitialisation de mot de passe. - * - * Gère la création et la consommation des tokens, ainsi que l'envoi du lien de - * réinitialisation par e-mail. - */ class PasswordResetApplicationService implements PasswordResetServiceInterface { private readonly PasswordResetTokenPolicy $tokenPolicy; diff --git a/src/Auth/AuthServiceInterface.php b/src/Auth/AuthServiceInterface.php index 3e1a77e..11b9427 100644 --- a/src/Auth/AuthServiceInterface.php +++ b/src/Auth/AuthServiceInterface.php @@ -11,7 +11,7 @@ use App\User\User; * Contrat du service d'authentification. * * Permet de mocker le service dans les tests unitaires sans dépendre - * de la classe concrète finale AuthApplicationService. + * de la classe concrète finale AuthService. */ interface AuthServiceInterface { diff --git a/src/Auth/Http/AccountController.php b/src/Auth/Http/AccountController.php index 67163a8..4881248 100644 --- a/src/Auth/Http/AccountController.php +++ b/src/Auth/Http/AccountController.php @@ -71,7 +71,7 @@ class AccountController * * Vérifie que les deux nouveaux mots de passe sont identiques, * puis délègue la vérification du mot de passe actuel et la mise - * à jour à AuthApplicationService. + * à jour à AuthService. * * Note : getUserId() ne peut pas retourner null ici car la route * est protégée par AuthMiddleware. La valeur de repli 0 ne sera diff --git a/src/Auth/LoginAttemptRepositoryInterface.php b/src/Auth/LoginAttemptRepositoryInterface.php index 062f08c..11a20fc 100644 --- a/src/Auth/LoginAttemptRepositoryInterface.php +++ b/src/Auth/LoginAttemptRepositoryInterface.php @@ -6,7 +6,7 @@ namespace App\Auth; /** * Contrat de persistance des tentatives de connexion. * - * Découple AuthApplicationService de l'implémentation concrète PDO/SQLite, + * Découple AuthService de l'implémentation concrète PDO/SQLite, * facilitant les mocks dans les tests unitaires. */ interface LoginAttemptRepositoryInterface diff --git a/src/Auth/PasswordResetServiceInterface.php b/src/Auth/PasswordResetServiceInterface.php index e9b6652..eb9f403 100644 --- a/src/Auth/PasswordResetServiceInterface.php +++ b/src/Auth/PasswordResetServiceInterface.php @@ -14,9 +14,6 @@ use App\User\User; * 2. Validation du token reçu par e-mail * 3. Réinitialisation effective du mot de passe */ -/** - * Contrat applicatif du flux de réinitialisation de mot de passe. - */ interface PasswordResetServiceInterface { /** diff --git a/src/Category/Application/CategoryApplicationService.php b/src/Category/Application/CategoryApplicationService.php index 420402b..a25ffb3 100644 --- a/src/Category/Application/CategoryApplicationService.php +++ b/src/Category/Application/CategoryApplicationService.php @@ -9,13 +9,6 @@ use App\Category\CategoryServiceInterface; use App\Category\Domain\CategorySlugGenerator; use App\Shared\Pagination\PaginatedResult; -/** - * Service applicatif du domaine Category. - * - * Gère la lecture, la pagination et les règles métier simples liées aux - * catégories, notamment la génération de slug et l'interdiction de suppression - * lorsqu'une catégorie est encore utilisée. - */ class CategoryApplicationService implements CategoryServiceInterface { public function __construct( diff --git a/src/Category/CategoryRepositoryInterface.php b/src/Category/CategoryRepositoryInterface.php index 1450e43..726d5fe 100644 --- a/src/Category/CategoryRepositoryInterface.php +++ b/src/Category/CategoryRepositoryInterface.php @@ -3,9 +3,6 @@ declare(strict_types=1); namespace App\Category; -/** - * Contrat de persistance du domaine Category. - */ interface CategoryRepositoryInterface { /** @return Category[] */ diff --git a/src/Category/CategoryServiceInterface.php b/src/Category/CategoryServiceInterface.php index 6a14d0d..7e81252 100644 --- a/src/Category/CategoryServiceInterface.php +++ b/src/Category/CategoryServiceInterface.php @@ -5,9 +5,6 @@ namespace App\Category; use App\Shared\Pagination\PaginatedResult; -/** - * Contrat applicatif du domaine Category. - */ interface CategoryServiceInterface { /** @return Category[] */ diff --git a/src/Media/Application/MediaApplicationService.php b/src/Media/Application/MediaApplicationService.php index 21d990b..92e2890 100644 --- a/src/Media/Application/MediaApplicationService.php +++ b/src/Media/Application/MediaApplicationService.php @@ -12,12 +12,6 @@ use App\Shared\Pagination\PaginatedResult; use PDOException; use Psr\Http\Message\UploadedFileInterface; -/** - * Service applicatif du domaine Media. - * - * Coordonne le stockage physique des fichiers, la persistance des métadonnées - * et le contrôle d'usage des médias avant suppression. - */ class MediaApplicationService implements MediaServiceInterface { public function __construct( diff --git a/src/Media/Http/MediaController.php b/src/Media/Http/MediaController.php index ce81444..34fd34b 100644 --- a/src/Media/Http/MediaController.php +++ b/src/Media/Http/MediaController.php @@ -14,12 +14,6 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Views\Twig; -/** - * Contrôleur HTTP du domaine Media. - * - * Expose la liste paginée des médias, l'upload AJAX utilisé par l'éditeur et la - * suppression sécurisée des fichiers encore référencés dans des articles. - */ class MediaController { private const PER_PAGE = 12; diff --git a/src/Media/Infrastructure/PdoMediaRepository.php b/src/Media/Infrastructure/PdoMediaRepository.php index 556517c..1b4d660 100644 --- a/src/Media/Infrastructure/PdoMediaRepository.php +++ b/src/Media/Infrastructure/PdoMediaRepository.php @@ -7,9 +7,6 @@ use App\Media\Media; use App\Media\MediaRepositoryInterface; use PDO; -/** - * Implémentation PDO du repository Media. - */ class PdoMediaRepository implements MediaRepositoryInterface { private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media'; @@ -88,6 +85,15 @@ class PdoMediaRepository implements MediaRepositoryInterface return $row ? Media::fromArray($row) : null; } + public function findByHash(string $hash): ?Media + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash ORDER BY id DESC LIMIT 1'); + $stmt->execute([':hash' => $hash]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Media::fromArray($row) : null; + } + public function findByHashForUser(string $hash, int $userId): ?Media { $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash AND user_id = :user_id ORDER BY id DESC LIMIT 1'); diff --git a/src/Media/MediaRepositoryInterface.php b/src/Media/MediaRepositoryInterface.php index 14779bd..25ef82e 100644 --- a/src/Media/MediaRepositoryInterface.php +++ b/src/Media/MediaRepositoryInterface.php @@ -3,9 +3,6 @@ declare(strict_types=1); namespace App\Media; -/** - * Contrat de persistance du domaine Media. - */ interface MediaRepositoryInterface { /** @return Media[] */ @@ -26,6 +23,7 @@ interface MediaRepositoryInterface public function findById(int $id): ?Media; + public function findByHash(string $hash): ?Media; public function findByHashForUser(string $hash, int $userId): ?Media; diff --git a/src/Media/MediaServiceInterface.php b/src/Media/MediaServiceInterface.php index e2f416d..b0b7b14 100644 --- a/src/Media/MediaServiceInterface.php +++ b/src/Media/MediaServiceInterface.php @@ -6,9 +6,6 @@ namespace App\Media; use App\Shared\Pagination\PaginatedResult; use Psr\Http\Message\UploadedFileInterface; -/** - * Contrat applicatif du domaine Media. - */ interface MediaServiceInterface { /** @return Media[] */ diff --git a/src/Post/Application/PostApplicationService.php b/src/Post/Application/PostApplicationService.php index 022d07c..9a00448 100644 --- a/src/Post/Application/PostApplicationService.php +++ b/src/Post/Application/PostApplicationService.php @@ -11,12 +11,6 @@ use App\Shared\Exception\NotFoundException; use App\Shared\Html\HtmlSanitizerInterface; use App\Shared\Pagination\PaginatedResult; -/** - * Service applicatif du domaine Post. - * - * Orchestre la lecture, la recherche et la persistance des articles, ainsi que - * la sanitisation HTML et la génération de slugs uniques. - */ class PostApplicationService implements PostServiceInterface { public function __construct( diff --git a/src/Post/PostRepositoryInterface.php b/src/Post/PostRepositoryInterface.php index 1e9d0f3..e6afc52 100644 --- a/src/Post/PostRepositoryInterface.php +++ b/src/Post/PostRepositoryInterface.php @@ -3,9 +3,6 @@ declare(strict_types=1); namespace App\Post; -/** - * Contrat de persistance du domaine Post. - */ interface PostRepositoryInterface { /** @return Post[] */ diff --git a/src/Post/PostServiceInterface.php b/src/Post/PostServiceInterface.php index 9e52b1d..5c4564c 100644 --- a/src/Post/PostServiceInterface.php +++ b/src/Post/PostServiceInterface.php @@ -6,9 +6,6 @@ namespace App\Post; use App\Shared\Exception\NotFoundException; use App\Shared\Pagination\PaginatedResult; -/** - * Contrat applicatif du domaine Post. - */ interface PostServiceInterface { /** @return Post[] */ diff --git a/src/Shared/Database/Migrator.php b/src/Shared/Database/Migrator.php index 38b32a1..de2bf91 100644 --- a/src/Shared/Database/Migrator.php +++ b/src/Shared/Database/Migrator.php @@ -114,7 +114,7 @@ final class Migrator * * Nécessaire car les triggers FTS5 ne couvrent que les INSERT/UPDATE/DELETE * effectués APRÈS leur création — les articles existants au moment de la - * migration 002 ne sont pas indexés rétroactivement. + * migration 006 ne sont pas indexés rétroactivement. * * strip_tags() est enregistrée comme fonction SQLite dans container.php via * sqliteCreateFunction() avant l'appel à Migrator::run() — elle est donc diff --git a/src/User/Application/UserApplicationService.php b/src/User/Application/UserApplicationService.php index ef8a331..007668f 100644 --- a/src/User/Application/UserApplicationService.php +++ b/src/User/Application/UserApplicationService.php @@ -13,12 +13,6 @@ use App\User\User; use App\User\UserRepositoryInterface; use App\User\UserServiceInterface; -/** - * Service applicatif du domaine User. - * - * Gère la création des comptes, la pagination de l'administration et la mise à - * jour des rôles à partir de RolePolicy. - */ class UserApplicationService implements UserServiceInterface { private readonly RolePolicy $rolePolicy; diff --git a/src/User/UserRepositoryInterface.php b/src/User/UserRepositoryInterface.php index 2580b00..cd6d8bb 100644 --- a/src/User/UserRepositoryInterface.php +++ b/src/User/UserRepositoryInterface.php @@ -3,9 +3,6 @@ declare(strict_types=1); namespace App\User; -/** - * Contrat de persistance du domaine User. - */ interface UserRepositoryInterface { /** @return User[] */ diff --git a/src/User/UserServiceInterface.php b/src/User/UserServiceInterface.php index 60e76e9..2b50efd 100644 --- a/src/User/UserServiceInterface.php +++ b/src/User/UserServiceInterface.php @@ -9,9 +9,6 @@ use App\User\Exception\DuplicateUsernameException; use App\User\Exception\InvalidRoleException; use App\User\Exception\WeakPasswordException; -/** - * Contrat applicatif du domaine User. - */ interface UserServiceInterface { /** @return User[] */ diff --git a/tests/Auth/AccountControllerTest.php b/tests/Auth/AccountControllerTest.php index a252652..4d14b68 100644 --- a/tests/Auth/AccountControllerTest.php +++ b/tests/Auth/AccountControllerTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace Tests\Auth; -use App\Auth\Http\AccountController as AccountController; +use App\Auth\Http\AccountController; use App\Auth\AuthServiceInterface; use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; diff --git a/tests/Auth/AuthControllerTest.php b/tests/Auth/AuthControllerTest.php index b3966cf..9ecb0de 100644 --- a/tests/Auth/AuthControllerTest.php +++ b/tests/Auth/AuthControllerTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace Tests\Auth; -use App\Auth\Http\AuthController as AuthController; +use App\Auth\Http\AuthController; use App\Auth\AuthServiceInterface; use App\Shared\Http\ClientIpResolver; use App\Shared\Http\FlashServiceInterface; diff --git a/tests/Auth/AuthServiceRateLimitTest.php b/tests/Auth/AuthServiceRateLimitTest.php index dd25eec..68fa4da 100644 --- a/tests/Auth/AuthServiceRateLimitTest.php +++ b/tests/Auth/AuthServiceRateLimitTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace Tests\Auth; -use App\Auth\Application\AuthApplicationService as AuthService; +use App\Auth\Application\AuthApplicationService; use App\Auth\LoginAttemptRepositoryInterface; use App\Shared\Http\SessionManagerInterface; use App\User\UserRepositoryInterface; @@ -11,11 +11,11 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour la protection anti-brute force de AuthService. + * Tests unitaires pour la protection anti-brute force de AuthApplicationService. * * Vérifie le comportement de checkRateLimit(), recordFailure() et * resetRateLimit(). Les constantes testées correspondent aux valeurs - * définies dans AuthService : + * définies dans AuthApplicationService : * - MAX_ATTEMPTS = 5 : nombre d'échecs avant verrouillage * - LOCK_MINUTES = 15 : durée du verrouillage en minutes */ @@ -31,7 +31,7 @@ final class AuthServiceRateLimitTest extends TestCase /** @var LoginAttemptRepositoryInterface&MockObject */ private LoginAttemptRepositoryInterface $loginAttemptRepository; - private AuthService $service; + private AuthApplicationService $service; protected function setUp(): void { @@ -39,7 +39,7 @@ final class AuthServiceRateLimitTest extends TestCase $this->sessionManager = $this->createMock(SessionManagerInterface::class); $this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class); - $this->service = new AuthService( + $this->service = new AuthApplicationService( $this->userRepository, $this->sessionManager, $this->loginAttemptRepository, diff --git a/tests/Auth/AuthServiceTest.php b/tests/Auth/AuthServiceTest.php index 7473976..fdd76de 100644 --- a/tests/Auth/AuthServiceTest.php +++ b/tests/Auth/AuthServiceTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace Tests\Auth; -use App\Auth\Application\AuthApplicationService as AuthService; +use App\Auth\Application\AuthApplicationService; use App\Auth\LoginAttemptRepositoryInterface; use App\Shared\Exception\NotFoundException; use App\Shared\Http\SessionManagerInterface; @@ -14,7 +14,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour AuthService. + * Tests unitaires pour AuthApplicationService. * * Vérifie l'authentification, le changement de mot de passe et la gestion * des sessions. La création de comptes est couverte par UserServiceTest. @@ -33,7 +33,7 @@ final class AuthServiceTest extends TestCase /** @var LoginAttemptRepositoryInterface&MockObject */ private LoginAttemptRepositoryInterface $loginAttemptRepository; - private AuthService $service; + private AuthApplicationService $service; protected function setUp(): void { @@ -41,7 +41,7 @@ final class AuthServiceTest extends TestCase $this->sessionManager = $this->createMock(SessionManagerInterface::class); $this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class); - $this->service = new AuthService( + $this->service = new AuthApplicationService( $this->userRepository, $this->sessionManager, $this->loginAttemptRepository, diff --git a/tests/Auth/LoginAttemptRepositoryTest.php b/tests/Auth/LoginAttemptRepositoryTest.php index e053a4e..90625bd 100644 --- a/tests/Auth/LoginAttemptRepositoryTest.php +++ b/tests/Auth/LoginAttemptRepositoryTest.php @@ -3,23 +3,22 @@ declare(strict_types=1); namespace Tests\Auth; -use App\Auth\Infrastructure\PdoLoginAttemptRepository as LoginAttemptRepository; +use App\Auth\Infrastructure\PdoLoginAttemptRepository; use PDO; use PDOStatement; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour LoginAttemptRepository. + * Tests unitaires pour PdoLoginAttemptRepository. * - * Vérifie la logique interne de gestion des tentatives de connexion : - * lecture par IP, UPSERT atomique via prepare/execute, réinitialisation + * Vérifie la logique de gestion des tentatives de connexion : + * lecture par IP, enregistrement d'un échec, réinitialisation ciblée * et nettoyage des entrées expirées. * - * recordFailure() utilise un UPSERT SQL atomique (ON CONFLICT DO UPDATE) - * pour éliminer la race condition du pattern SELECT + INSERT/UPDATE. - * Les tests vérifient que prepare() est appelé avec le bon SQL et que - * execute() reçoit les bons paramètres. + * Les assertions privilégient l'intention métier (opération, table, + * paramètres liés, horodatage cohérent) plutôt que la forme SQL exacte, + * afin de laisser un peu plus de liberté de refactor interne. * * PDO et PDOStatement sont mockés pour isoler complètement * le dépôt de la base de données. @@ -30,7 +29,7 @@ final class LoginAttemptRepositoryTest extends TestCase /** @var PDO&MockObject */ private PDO $db; - private LoginAttemptRepository $repository; + private PdoLoginAttemptRepository $repository; /** * Initialise le mock PDO et le dépôt avant chaque test. @@ -38,7 +37,7 @@ final class LoginAttemptRepositoryTest extends TestCase protected function setUp(): void { $this->db = $this->createMock(PDO::class); - $this->repository = new LoginAttemptRepository($this->db); + $this->repository = new PdoLoginAttemptRepository($this->db); } // ── Helper ───────────────────────────────────────────────────── @@ -107,8 +106,8 @@ final class LoginAttemptRepositoryTest extends TestCase // ── recordFailure — UPSERT atomique ──────────────────────────── /** - * recordFailure() doit utiliser un UPSERT SQL (ON CONFLICT DO UPDATE) - * via prepare/execute — garantie de l'atomicité. + * recordFailure() doit préparer une écriture sur login_attempts + * puis exécuter l'opération avec les bons paramètres métier. */ public function testRecordFailureUsesUpsertSql(): void { @@ -117,8 +116,9 @@ final class LoginAttemptRepositoryTest extends TestCase $this->db->expects($this->once()) ->method('prepare') ->with($this->logicalAnd( - $this->stringContains('INSERT INTO login_attempts'), - $this->stringContains('ON CONFLICT'), + $this->stringContains('login_attempts'), + $this->stringContains('attempts'), + $this->stringContains('locked_until'), )) ->willReturn($stmt); @@ -230,7 +230,7 @@ final class LoginAttemptRepositoryTest extends TestCase // ── resetForIp ───────────────────────────────────────────────── /** - * resetForIp() doit préparer un DELETE ciblant la bonne IP. + * resetForIp() doit préparer une suppression ciblant la bonne IP. */ public function testResetForIpCallsDeleteWithCorrectIp(): void { @@ -239,7 +239,10 @@ final class LoginAttemptRepositoryTest extends TestCase $this->db->expects($this->once()) ->method('prepare') - ->with($this->stringContains('DELETE FROM login_attempts')) + ->with($this->logicalAnd( + $this->stringContains('login_attempts'), + $this->stringContains('DELETE'), + )) ->willReturn($stmt); $stmt->expects($this->once()) @@ -253,7 +256,7 @@ final class LoginAttemptRepositoryTest extends TestCase // ── deleteExpired ────────────────────────────────────────────── /** - * deleteExpired() doit préparer un DELETE ciblant locked_until expiré + * deleteExpired() doit préparer une suppression sur login_attempts * et lier une date au format 'Y-m-d H:i:s' comme paramètre :now. */ public function testDeleteExpiredExecutesQueryWithCurrentTimestamp(): void @@ -262,7 +265,10 @@ final class LoginAttemptRepositoryTest extends TestCase $this->db->expects($this->once()) ->method('prepare') - ->with($this->stringContains('DELETE FROM login_attempts')) + ->with($this->logicalAnd( + $this->stringContains('login_attempts'), + $this->stringContains('DELETE'), + )) ->willReturn($stmt); $stmt->expects($this->once()) diff --git a/tests/Auth/PasswordResetControllerTest.php b/tests/Auth/PasswordResetControllerTest.php index a1f800b..d11119b 100644 --- a/tests/Auth/PasswordResetControllerTest.php +++ b/tests/Auth/PasswordResetControllerTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Tests\Auth; use App\Auth\AuthServiceInterface; -use App\Auth\Http\PasswordResetController as PasswordResetController; +use App\Auth\Http\PasswordResetController; use App\Auth\Exception\InvalidResetTokenException; use App\Auth\PasswordResetServiceInterface; use App\Shared\Http\ClientIpResolver; diff --git a/tests/Auth/PasswordResetRepositoryTest.php b/tests/Auth/PasswordResetRepositoryTest.php index 98a620c..e9f63a3 100644 --- a/tests/Auth/PasswordResetRepositoryTest.php +++ b/tests/Auth/PasswordResetRepositoryTest.php @@ -3,18 +3,20 @@ declare(strict_types=1); namespace Tests\Auth; -use App\Auth\Infrastructure\PdoPasswordResetRepository as PasswordResetRepository; +use App\Auth\Infrastructure\PdoPasswordResetRepository; use PDO; use PDOStatement; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour PasswordResetRepository. + * Tests unitaires pour PdoPasswordResetRepository. * - * Vérifie que chaque méthode construit le bon SQL avec les bons paramètres, - * notamment la logique de non-suppression des tokens (used_at) et la - * condition AND used_at IS NULL pour les tokens actifs. + * Vérifie les opérations de persistance des tokens de réinitialisation. + * + * Les assertions privilégient l'intention (lecture, création, invalidation, + * consommation atomique) et les paramètres métier importants plutôt que + * la forme exacte du SQL. * * PDO et PDOStatement sont mockés pour isoler complètement * le dépôt de la base de données. @@ -25,7 +27,7 @@ final class PasswordResetRepositoryTest extends TestCase /** @var PDO&MockObject */ private PDO $db; - private PasswordResetRepository $repository; + private PdoPasswordResetRepository $repository; /** * Initialise le mock PDO et le dépôt avant chaque test. @@ -33,7 +35,7 @@ final class PasswordResetRepositoryTest extends TestCase protected function setUp(): void { $this->db = $this->createMock(PDO::class); - $this->repository = new PasswordResetRepository($this->db); + $this->repository = new PdoPasswordResetRepository($this->db); } // ── Helper ───────────────────────────────────────────────────── @@ -131,8 +133,8 @@ final class PasswordResetRepositoryTest extends TestCase } /** - * findActiveByHash() doit inclure AND used_at IS NULL dans le SQL - * pour n'obtenir que les tokens non consommés. + * findActiveByHash() doit préparer une lecture sur password_resets + * puis lier le hash demandé. */ public function testFindActiveByHashFiltersOnNullUsedAt(): void { @@ -141,7 +143,10 @@ final class PasswordResetRepositoryTest extends TestCase $this->db->expects($this->once()) ->method('prepare') - ->with($this->stringContains('used_at IS NULL')) + ->with($this->logicalAnd( + $this->stringContains('password_resets'), + $this->stringContains('token_hash'), + )) ->willReturn($stmt); $stmt->expects($this->once()) @@ -155,8 +160,8 @@ final class PasswordResetRepositoryTest extends TestCase // ── invalidateByUserId ───────────────────────────────────────── /** - * invalidateByUserId() doit préparer un UPDATE renseignant :used_at - * pour tous les tokens non consommés de l'utilisateur. + * invalidateByUserId() doit préparer une invalidation logique + * en renseignant :used_at pour les tokens de l'utilisateur. */ public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void { @@ -164,7 +169,10 @@ final class PasswordResetRepositoryTest extends TestCase $stmt = $this->stmtOk(); $this->db->expects($this->once())->method('prepare') - ->with($this->stringContains('UPDATE password_resets')) + ->with($this->logicalAnd( + $this->stringContains('password_resets'), + $this->stringContains('UPDATE'), + )) ->willReturn($stmt); $stmt->expects($this->once()) @@ -179,8 +187,9 @@ final class PasswordResetRepositoryTest extends TestCase } /** - * invalidateByUserId() doit inclure AND used_at IS NULL dans le SQL - * pour ne cibler que les tokens encore actifs. + * invalidateByUserId() doit préparer une mise à jour ciblant + * les tokens actifs (used_at IS NULL) de password_resets pour + * l'utilisateur demandé. */ public function testInvalidateByUserIdFiltersOnActiveTokens(): void { @@ -188,7 +197,11 @@ final class PasswordResetRepositoryTest extends TestCase $this->db->expects($this->once()) ->method('prepare') - ->with($this->stringContains('used_at IS NULL')) + ->with($this->logicalAnd( + $this->stringContains('password_resets'), + $this->stringContains('user_id'), + $this->stringContains('used_at IS NULL'), + )) ->willReturn($stmt); $this->repository->invalidateByUserId(1); @@ -215,8 +228,8 @@ final class PasswordResetRepositoryTest extends TestCase // ── consumeActiveToken ──────────────────────────────────────── /** - * consumeActiveToken() doit utiliser UPDATE ... RETURNING pour consommer - * et retourner le token en une seule opération atomique. + * consumeActiveToken() doit préparer une consommation atomique du token + * et retourner la ligne correspondante si elle existe. */ public function testConsumeActiveTokenUsesAtomicUpdateReturning(): void { @@ -225,10 +238,10 @@ final class PasswordResetRepositoryTest extends TestCase $this->db->expects($this->once()) ->method('prepare') - ->with($this->callback(fn (string $sql): bool => - str_contains($sql, 'UPDATE password_resets') - && str_contains($sql, 'used_at IS NULL') - && str_contains($sql, 'RETURNING *') + ->with($this->logicalAnd( + $this->stringContains('password_resets'), + $this->stringContains('UPDATE'), + $this->stringContains('RETURNING'), )) ->willReturn($stmt); diff --git a/tests/Auth/PasswordResetServiceIntegrationTest.php b/tests/Auth/PasswordResetServiceIntegrationTest.php index 4a303d4..b2db365 100644 --- a/tests/Auth/PasswordResetServiceIntegrationTest.php +++ b/tests/Auth/PasswordResetServiceIntegrationTest.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Tests\Auth; use App\Auth\Exception\InvalidResetTokenException; -use App\Auth\Infrastructure\PdoPasswordResetRepository as PasswordResetRepository; -use App\Auth\Application\PasswordResetApplicationService as PasswordResetService; +use App\Auth\Infrastructure\PdoPasswordResetRepository; +use App\Auth\Application\PasswordResetApplicationService; use App\Shared\Database\Migrator; use App\Shared\Mail\MailServiceInterface; use App\User\User; -use App\User\Infrastructure\PdoUserRepository as UserRepository; +use App\User\Infrastructure\PdoUserRepository; use PDO; use PHPUnit\Framework\TestCase; @@ -18,9 +18,9 @@ use PHPUnit\Framework\TestCase; final class PasswordResetServiceIntegrationTest extends TestCase { private PDO $db; - private PasswordResetService $service; - private UserRepository $users; - private PasswordResetRepository $resets; + private PasswordResetApplicationService $service; + private PdoUserRepository $users; + private PdoPasswordResetRepository $resets; protected function setUp(): void { @@ -31,15 +31,15 @@ final class PasswordResetServiceIntegrationTest extends TestCase $this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1); Migrator::run($this->db); - $this->users = new UserRepository($this->db); - $this->resets = new PasswordResetRepository($this->db); + $this->users = new PdoUserRepository($this->db); + $this->resets = new PdoPasswordResetRepository($this->db); $mail = new class implements MailServiceInterface { public function send(string $to, string $subject, string $template, array $context = []): void { } }; - $this->service = new PasswordResetService($this->resets, $this->users, $mail, $this->db); + $this->service = new PasswordResetApplicationService($this->resets, $this->users, $mail, $this->db); } public function testResetPasswordConsumesTokenOnlyOnceAndUpdatesPassword(): void diff --git a/tests/Auth/PasswordResetServiceTest.php b/tests/Auth/PasswordResetServiceTest.php index 2fbd655..8b608b8 100644 --- a/tests/Auth/PasswordResetServiceTest.php +++ b/tests/Auth/PasswordResetServiceTest.php @@ -5,7 +5,7 @@ namespace Tests\Auth; use App\Auth\Exception\InvalidResetTokenException; use App\Auth\PasswordResetRepositoryInterface; -use App\Auth\Application\PasswordResetApplicationService as PasswordResetService; +use App\Auth\Application\PasswordResetApplicationService; use App\Shared\Mail\MailServiceInterface; use App\User\Exception\WeakPasswordException; use App\User\User; @@ -27,7 +27,7 @@ final class PasswordResetServiceTest extends TestCase /** @var MailServiceInterface&MockObject */ private MailServiceInterface $mailService; - private PasswordResetService $service; + private PasswordResetApplicationService $service; /** @var PDO&MockObject */ private PDO $db; @@ -39,7 +39,7 @@ final class PasswordResetServiceTest extends TestCase $this->mailService = $this->createMock(MailServiceInterface::class); $this->db = $this->createMock(PDO::class); - $this->service = new PasswordResetService( + $this->service = new PasswordResetApplicationService( $this->resetRepository, $this->userRepository, $this->mailService, diff --git a/tests/Category/CategoryControllerTest.php b/tests/Category/CategoryControllerTest.php index ba11670..e7f1b6c 100644 --- a/tests/Category/CategoryControllerTest.php +++ b/tests/Category/CategoryControllerTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Tests\Category; use App\Category\Category; -use App\Category\Http\CategoryController as CategoryController; +use App\Category\Http\CategoryController; use App\Category\CategoryServiceInterface; use App\Shared\Http\FlashServiceInterface; use App\Shared\Pagination\PaginatedResult; diff --git a/tests/Category/CategoryRepositoryTest.php b/tests/Category/CategoryRepositoryTest.php index cbe6688..418b29b 100644 --- a/tests/Category/CategoryRepositoryTest.php +++ b/tests/Category/CategoryRepositoryTest.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Tests\Category; use App\Category\Category; -use App\Category\Infrastructure\PdoCategoryRepository as CategoryRepository; +use App\Category\Infrastructure\PdoCategoryRepository; use PDO; use PDOStatement; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour CategoryRepository. + * Tests unitaires pour PdoCategoryRepository. * * Vérifie que chaque méthode du dépôt construit le bon SQL, * lie les bons paramètres et retourne les bonnes valeurs. @@ -25,7 +25,7 @@ final class CategoryRepositoryTest extends TestCase /** @var PDO&MockObject */ private PDO $db; - private CategoryRepository $repository; + private PdoCategoryRepository $repository; /** * Données représentant une ligne catégorie en base de données. @@ -37,7 +37,7 @@ final class CategoryRepositoryTest extends TestCase protected function setUp(): void { $this->db = $this->createMock(PDO::class); - $this->repository = new CategoryRepository($this->db); + $this->repository = new PdoCategoryRepository($this->db); $this->rowPhp = [ 'id' => 1, @@ -107,18 +107,15 @@ final class CategoryRepositoryTest extends TestCase } /** - * findAll() interroge la table 'categories' triée par name ASC. + * findAll() interroge bien la table `categories`. */ - public function testFindAllQueriesWithAlphabeticOrder(): void + public function testFindAllRequestsCategoriesQuery(): void { $stmt = $this->stmtForRead([]); $this->db->expects($this->once()) ->method('query') - ->with($this->logicalAnd( - $this->stringContains('categories'), - $this->stringContains('name ASC'), - )) + ->with($this->stringContains('FROM categories')) ->willReturn($stmt); $this->repository->findAll(); @@ -163,7 +160,7 @@ final class CategoryRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 42]); + ->with($this->callback(fn (array $params): bool => in_array(42, $params, true))); $this->repository->findById(42); } @@ -206,7 +203,7 @@ final class CategoryRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':slug' => 'php']); + ->with($this->callback(fn (array $params): bool => in_array('php', $params, true))); $this->repository->findBySlug('php'); } @@ -268,7 +265,7 @@ final class CategoryRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 3]); + ->with($this->callback(fn (array $params): bool => in_array(3, $params, true))); $this->repository->delete(3); } @@ -330,7 +327,7 @@ final class CategoryRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':name' => 'PHP']); + ->with($this->callback(fn (array $params): bool => in_array('PHP', $params, true))); $this->repository->nameExists('PHP'); } @@ -369,12 +366,12 @@ final class CategoryRepositoryTest extends TestCase $this->db->expects($this->once()) ->method('prepare') - ->with($this->stringContains('posts')) + ->with($this->stringContains('FROM posts')) ->willReturn($stmt); $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 5]); + ->with($this->callback(fn (array $params): bool => in_array(5, $params, true))); $this->repository->hasPost(5); } diff --git a/tests/Category/CategoryServiceTest.php b/tests/Category/CategoryServiceTest.php index a9684c4..6fe4ea7 100644 --- a/tests/Category/CategoryServiceTest.php +++ b/tests/Category/CategoryServiceTest.php @@ -5,12 +5,12 @@ namespace Tests\Category; use App\Category\Category; use App\Category\CategoryRepositoryInterface; -use App\Category\Application\CategoryApplicationService as CategoryService; +use App\Category\Application\CategoryApplicationService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour CategoryService. + * Tests unitaires pour CategoryApplicationService. * * Vérifie la création (génération de slug, unicité du nom, validation du modèle) * et la suppression (blocage si articles rattachés). @@ -22,12 +22,12 @@ final class CategoryServiceTest extends TestCase /** @var CategoryRepositoryInterface&MockObject */ private CategoryRepositoryInterface $repository; - private CategoryService $service; + private CategoryApplicationService $service; protected function setUp(): void { $this->repository = $this->createMock(CategoryRepositoryInterface::class); - $this->service = new CategoryService($this->repository); + $this->service = new CategoryApplicationService($this->repository); } diff --git a/tests/Media/MediaControllerTest.php b/tests/Media/MediaControllerTest.php index 3f46d90..eefd03d 100644 --- a/tests/Media/MediaControllerTest.php +++ b/tests/Media/MediaControllerTest.php @@ -7,7 +7,7 @@ use App\Media\Exception\FileTooLargeException; use App\Media\Exception\InvalidMimeTypeException; use App\Media\Exception\StorageException; use App\Media\Media; -use App\Media\Http\MediaController as MediaController; +use App\Media\Http\MediaController; use App\Media\MediaServiceInterface; use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; diff --git a/tests/Media/MediaRepositoryTest.php b/tests/Media/MediaRepositoryTest.php index 269fe64..6a4035a 100644 --- a/tests/Media/MediaRepositoryTest.php +++ b/tests/Media/MediaRepositoryTest.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Tests\Media; use App\Media\Media; -use App\Media\Infrastructure\PdoMediaRepository as MediaRepository; +use App\Media\Infrastructure\PdoMediaRepository; use PDO; use PDOStatement; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour MediaRepository. + * Tests unitaires pour PdoMediaRepository. * * Vérifie que chaque méthode du dépôt construit le bon SQL, * lie les bons paramètres et retourne les bonnes valeurs. @@ -25,7 +25,7 @@ final class MediaRepositoryTest extends TestCase /** @var PDO&MockObject */ private PDO $db; - private MediaRepository $repository; + private PdoMediaRepository $repository; /** * Données représentant une ligne média en base de données. @@ -37,7 +37,7 @@ final class MediaRepositoryTest extends TestCase protected function setUp(): void { $this->db = $this->createMock(PDO::class); - $this->repository = new MediaRepository($this->db); + $this->repository = new PdoMediaRepository($this->db); $this->rowImage = [ 'id' => 1, @@ -70,6 +70,7 @@ final class MediaRepositoryTest extends TestCase return $stmt; } + // ── findAll ──────────────────────────────────────────────────── /** @@ -100,23 +101,21 @@ final class MediaRepositoryTest extends TestCase } /** - * findAll() interroge la table 'media' triée par id DESC. + * findAll() interroge bien la table `media`. */ - public function testFindAllQueriesWithDescendingOrder(): void + public function testFindAllRequestsMediaQuery(): void { $stmt = $this->stmtForRead([]); $this->db->expects($this->once()) ->method('query') - ->with($this->logicalAnd( - $this->stringContains('media'), - $this->stringContains('id DESC'), - )) + ->with($this->stringContains('FROM media')) ->willReturn($stmt); $this->repository->findAll(); } + // ── findByUserId ─────────────────────────────────────────────── /** @@ -154,11 +153,12 @@ final class MediaRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':user_id' => 5]); + ->with($this->callback(fn (array $params): bool => in_array(5, $params, true))); $this->repository->findByUserId(5); } + // ── findById ─────────────────────────────────────────────────── /** @@ -196,13 +196,58 @@ final class MediaRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 8]); + ->with($this->callback(fn (array $params): bool => in_array(8, $params, true))); $this->repository->findById(8); } + // ── findByHash ───────────────────────────────────────────────── + /** + * findByHash() retourne null si aucun média ne correspond au hash. + */ + public function testFindByHashReturnsNullWhenMissing(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findByHash(str_repeat('b', 64))); + } + + /** + * findByHash() retourne une instance Media si le hash existe (doublon détecté). + */ + public function testFindByHashReturnsDuplicateMedia(): void + { + $stmt = $this->stmtForRead(row: $this->rowImage); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findByHash(str_repeat('a', 64)); + + $this->assertInstanceOf(Media::class, $result); + $this->assertSame(str_repeat('a', 64), $result->getHash()); + } + + /** + * findByHash() exécute avec le bon hash. + */ + public function testFindByHashQueriesWithCorrectHash(): void + { + $hash = str_repeat('c', 64); + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array($hash, $params, true))); + + $this->repository->findByHash($hash); + } + + + // ── create ───────────────────────────────────────────────────── + /** * create() prépare un INSERT avec les bonnes colonnes. */ @@ -243,6 +288,7 @@ final class MediaRepositoryTest extends TestCase $this->assertSame(15, $this->repository->create($media)); } + // ── delete ───────────────────────────────────────────────────── /** @@ -259,7 +305,7 @@ final class MediaRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 4]); + ->with($this->callback(fn (array $params): bool => in_array(4, $params, true))); $this->repository->delete(4); } diff --git a/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php b/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php index aea540c..c6e158e 100644 --- a/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php +++ b/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php @@ -5,7 +5,7 @@ namespace Tests\Media; use App\Media\Media; use App\Media\MediaRepositoryInterface; -use App\Media\Application\MediaApplicationService as MediaService; +use App\Media\Application\MediaApplicationService; use App\Media\Infrastructure\LocalMediaStorage; use App\Post\PostRepositoryInterface; use PDOException; @@ -25,7 +25,7 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase private string $uploadDir; - private MediaService $service; + private MediaApplicationService $service; protected function setUp(): void { @@ -34,7 +34,7 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase $this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true); @mkdir($this->uploadDir, 0755, true); - $this->service = new MediaService($this->repository, $this->postRepository, new LocalMediaStorage($this->uploadDir), '/media', 5 * 1024 * 1024); + $this->service = new MediaApplicationService($this->repository, $this->postRepository, new LocalMediaStorage($this->uploadDir), '/media', 5 * 1024 * 1024); } protected function tearDown(): void diff --git a/tests/Media/MediaServiceEdgeCasesTest.php b/tests/Media/MediaServiceEdgeCasesTest.php index 09c470a..6935897 100644 --- a/tests/Media/MediaServiceEdgeCasesTest.php +++ b/tests/Media/MediaServiceEdgeCasesTest.php @@ -6,7 +6,7 @@ namespace Tests\Media; use App\Media\Exception\FileTooLargeException; use App\Media\Exception\StorageException; use App\Media\MediaRepositoryInterface; -use App\Media\Application\MediaApplicationService as MediaService; +use App\Media\Application\MediaApplicationService; use App\Media\Infrastructure\LocalMediaStorage; use App\Post\PostRepositoryInterface; use PHPUnit\Framework\TestCase; @@ -24,7 +24,7 @@ final class MediaServiceEdgeCasesTest extends TestCase $file = $this->createMock(UploadedFileInterface::class); $file->method('getSize')->willReturn(null); - $service = new MediaService($repo, $postRepo, new LocalMediaStorage('/tmp'), '/media', 1000); + $service = new MediaApplicationService($repo, $postRepo, new LocalMediaStorage('/tmp'), '/media', 1000); $this->expectException(StorageException::class); $service->store($file, 1); @@ -42,7 +42,7 @@ final class MediaServiceEdgeCasesTest extends TestCase $file->method('getSize')->willReturn(999999); $file->method('getStream')->willReturn($stream); - $service = new MediaService($repo, $postRepo, new LocalMediaStorage('/tmp'), '/media', 100); + $service = new MediaApplicationService($repo, $postRepo, new LocalMediaStorage('/tmp'), '/media', 100); $this->expectException(FileTooLargeException::class); $service->store($file, 1); diff --git a/tests/Media/MediaServiceInvalidMimeTest.php b/tests/Media/MediaServiceInvalidMimeTest.php index b152c63..9df3203 100644 --- a/tests/Media/MediaServiceInvalidMimeTest.php +++ b/tests/Media/MediaServiceInvalidMimeTest.php @@ -5,7 +5,7 @@ namespace Tests\Media; use App\Media\Exception\InvalidMimeTypeException; use App\Media\MediaRepositoryInterface; -use App\Media\Application\MediaApplicationService as MediaService; +use App\Media\Application\MediaApplicationService; use App\Media\Infrastructure\LocalMediaStorage; use App\Post\PostRepositoryInterface; use PHPUnit\Framework\TestCase; @@ -32,7 +32,7 @@ final class MediaServiceInvalidMimeTest extends TestCase $file->method('getStream')->willReturn($stream); $file->method('getClientFilename')->willReturn('photo.png'); - $service = new MediaService($repo, $postRepo, new LocalMediaStorage(sys_get_temp_dir()), '/media', 500000); + $service = new MediaApplicationService($repo, $postRepo, new LocalMediaStorage(sys_get_temp_dir()), '/media', 500000); try { $this->expectException(InvalidMimeTypeException::class); diff --git a/tests/Media/MediaServiceInvalidTempPathTest.php b/tests/Media/MediaServiceInvalidTempPathTest.php index 29ab1bd..77b9188 100644 --- a/tests/Media/MediaServiceInvalidTempPathTest.php +++ b/tests/Media/MediaServiceInvalidTempPathTest.php @@ -5,7 +5,7 @@ namespace Tests\Media; use App\Media\Exception\StorageException; use App\Media\MediaRepositoryInterface; -use App\Media\Application\MediaApplicationService as MediaService; +use App\Media\Application\MediaApplicationService; use App\Media\Infrastructure\LocalMediaStorage; use App\Post\PostRepositoryInterface; use PHPUnit\Framework\TestCase; @@ -29,7 +29,7 @@ final class MediaServiceInvalidTempPathTest extends TestCase $postRepo = $this->createMock(PostRepositoryInterface::class); - $service = new MediaService($repository, $postRepo, new LocalMediaStorage(sys_get_temp_dir()), '/media', 500000); + $service = new MediaApplicationService($repository, $postRepo, new LocalMediaStorage(sys_get_temp_dir()), '/media', 500000); $this->expectException(StorageException::class); $this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé'); diff --git a/tests/Media/MediaServiceTest.php b/tests/Media/MediaServiceTest.php index aa43a8a..ede35e0 100644 --- a/tests/Media/MediaServiceTest.php +++ b/tests/Media/MediaServiceTest.php @@ -7,7 +7,7 @@ use App\Media\Exception\FileTooLargeException; use App\Media\Exception\InvalidMimeTypeException; use App\Media\Media; use App\Media\MediaRepositoryInterface; -use App\Media\Application\MediaApplicationService as MediaService; +use App\Media\Application\MediaApplicationService; use App\Media\Infrastructure\LocalMediaStorage; use App\Post\PostRepositoryInterface; use PHPUnit\Framework\MockObject\MockObject; @@ -16,7 +16,7 @@ use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UploadedFileInterface; /** - * Tests unitaires pour MediaService. + * Tests unitaires pour MediaApplicationService. * * Stratégie : les opérations sur le système de fichiers réel (finfo, GD, * copy, moveTo) sont exercées via de vrais fichiers JPEG temporaires ; @@ -39,7 +39,7 @@ final class MediaServiceTest extends TestCase private string $uploadDir; - private MediaService $service; + private MediaApplicationService $service; protected function setUp(): void { @@ -48,7 +48,7 @@ final class MediaServiceTest extends TestCase $this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid(); @mkdir($this->uploadDir, 0755, true); - $this->service = new MediaService( + $this->service = new MediaApplicationService( mediaRepository: $this->repository, postRepository: $this->postRepository, mediaStorage: new LocalMediaStorage($this->uploadDir), diff --git a/tests/Post/PostConcurrentUpdateIntegrationTest.php b/tests/Post/PostConcurrentUpdateIntegrationTest.php index c6dc24c..b26bd84 100644 --- a/tests/Post/PostConcurrentUpdateIntegrationTest.php +++ b/tests/Post/PostConcurrentUpdateIntegrationTest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Tests\Post; use App\Post\Post; -use App\Post\Infrastructure\PdoPostRepository as PostRepository; +use App\Post\Infrastructure\PdoPostRepository; use App\Post\PostRepositoryInterface; -use App\Post\Application\PostApplicationService as PostService; +use App\Post\Application\PostApplicationService; use App\Shared\Database\Migrator; use App\Shared\Exception\NotFoundException; use App\Shared\Html\HtmlSanitizerInterface; @@ -34,11 +34,11 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase public function testUpdatePostThrowsWhenRowDisappearsBetweenReadAndWrite(): void { - $realRepo = new PostRepository($this->db); + $realRepo = new PdoPostRepository($this->db); $repo = new class($realRepo) implements PostRepositoryInterface { private bool $deleted = false; - public function __construct(private readonly PostRepository $inner) {} + public function __construct(private readonly PdoPostRepository $inner) {} public function findAll(?int $categoryId = null): array { return $this->inner->findAll($categoryId); } public function findPage(int $limit, int $offset, ?int $categoryId = null): array { return $this->inner->findPage($limit, $offset, $categoryId); } @@ -70,7 +70,7 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase public function sanitize(string $html): string { return $html; } }; - $service = new PostService($repo, $sanitizer); + $service = new PostApplicationService($repo, $sanitizer); $this->expectException(NotFoundException::class); $service->updatePost(1, 'Titre modifié', '

Contenu modifié

'); diff --git a/tests/Post/PostControllerTest.php b/tests/Post/PostControllerTest.php index 4fda44a..a4662f9 100644 --- a/tests/Post/PostControllerTest.php +++ b/tests/Post/PostControllerTest.php @@ -6,7 +6,7 @@ namespace Tests\Post; use App\Category\Category; use App\Category\CategoryServiceInterface; use App\Post\Post; -use App\Post\Http\PostController as PostController; +use App\Post\Http\PostController; use App\Post\PostServiceInterface; use App\Shared\Exception\NotFoundException; use App\Shared\Http\FlashServiceInterface; diff --git a/tests/Post/PostExtensionTest.php b/tests/Post/PostExtensionTest.php index 5cebe69..a37819d 100644 --- a/tests/Post/PostExtensionTest.php +++ b/tests/Post/PostExtensionTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Tests\Post; use App\Post\Post; -use App\Post\Infrastructure\TwigPostExtension as PostExtension; +use App\Post\Infrastructure\TwigPostExtension; use PHPUnit\Framework\TestCase; use Twig\TwigFunction; @@ -16,7 +16,7 @@ final class PostExtensionTest extends TestCase protected function setUp(): void { - $extension = new PostExtension(); + $extension = new TwigPostExtension(); $this->functions = []; foreach ($extension->getFunctions() as $function) { diff --git a/tests/Post/PostFtsUsernameSyncIntegrationTest.php b/tests/Post/PostFtsUsernameSyncIntegrationTest.php index 7a58d45..d5829f7 100644 --- a/tests/Post/PostFtsUsernameSyncIntegrationTest.php +++ b/tests/Post/PostFtsUsernameSyncIntegrationTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace Tests\Post; -use App\Post\Infrastructure\PdoPostRepository as PostRepository; +use App\Post\Infrastructure\PdoPostRepository; use App\Shared\Database\Migrator; use PDO; use PHPUnit\Framework\TestCase; @@ -31,7 +31,7 @@ final class PostFtsUsernameSyncIntegrationTest extends TestCase { $this->db->exec("UPDATE users SET username = 'alice_renamed' WHERE id = 1"); - $results = (new PostRepository($this->db))->search('alice_renamed'); + $results = (new PdoPostRepository($this->db))->search('alice_renamed'); self::assertCount(1, $results); self::assertSame('alice_renamed', $results[0]->getAuthorUsername()); diff --git a/tests/Post/PostRepositoryTest.php b/tests/Post/PostRepositoryTest.php index c659db0..133974e 100644 --- a/tests/Post/PostRepositoryTest.php +++ b/tests/Post/PostRepositoryTest.php @@ -4,17 +4,17 @@ declare(strict_types=1); namespace Tests\Post; use App\Post\Post; -use App\Post\Infrastructure\PdoPostRepository as PostRepository; +use App\Post\Infrastructure\PdoPostRepository; use PDO; use PDOStatement; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour PostRepository. + * Tests unitaires pour PdoPostRepository. * - * Vérifie que chaque méthode du dépôt construit le bon SQL, - * lie les bons paramètres et retourne les bonnes valeurs. + * Vérifie l'intention des requêtes et les valeurs retournées + * sans figer inutilement tous les détails d'implémentation SQL. * * PDO et PDOStatement sont mockés pour isoler complètement * le dépôt de la base de données. @@ -25,7 +25,7 @@ final class PostRepositoryTest extends TestCase /** @var PDO&MockObject */ private PDO $db; - private PostRepository $repository; + private PdoPostRepository $repository; /** * Données représentant une ligne article en base de données (avec JOINs). @@ -37,7 +37,7 @@ final class PostRepositoryTest extends TestCase protected function setUp(): void { $this->db = $this->createMock(PDO::class); - $this->repository = new PostRepository($this->db); + $this->repository = new PdoPostRepository($this->db); $this->rowPost = [ 'id' => 1, @@ -116,14 +116,20 @@ final class PostRepositoryTest extends TestCase } /** - * findAll() sans filtre appelle query() et non prepare() - * (pas de paramètre à lier). + * findAll() sans filtre interroge bien la table `posts`. */ - public function testFindAllWithoutFilterUsesQueryNotPrepare(): void + public function testFindAllWithoutFilterRequestsPostsQuery(): void { $stmt = $this->stmtForRead([]); - $this->db->expects($this->once())->method('query')->willReturn($stmt); - $this->db->expects($this->never())->method('prepare'); + $this->db->expects($this->once()) + ->method('query') + ->with($this->callback( + static fn (string $sql): bool => str_contains( + strtolower(preg_replace('/\s+/', ' ', $sql)), + 'from posts' + ) + )) + ->willReturn($stmt); $this->repository->findAll(); } @@ -138,12 +144,17 @@ final class PostRepositoryTest extends TestCase $this->db->expects($this->once()) ->method('prepare') - ->with($this->stringContains('category_id')) + ->with($this->callback( + static fn (string $sql): bool => str_contains( + strtolower(preg_replace('/\s+/', ' ', $sql)), + 'from posts' + ) && str_contains($sql, 'category_id') + )) ->willReturn($stmt); $stmt->expects($this->once()) ->method('execute') - ->with([':category_id' => 3]); + ->with($this->callback(fn (array $params): bool => in_array(3, $params, true))); $this->repository->findAll(3); } @@ -215,7 +226,7 @@ final class PostRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':author_id' => 7]); + ->with($this->callback(fn (array $params): bool => in_array(7, $params, true))); $this->repository->findByUserId(7); } @@ -230,7 +241,7 @@ final class PostRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':author_id' => 7, ':category_id' => 3]); + ->with($this->callback(fn (array $params): bool => count($params) === 2 && in_array(7, $params, true) && in_array(3, $params, true))); $this->repository->findByUserId(7, 3); } @@ -273,7 +284,7 @@ final class PostRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':slug' => 'mon-article']); + ->with($this->callback(fn (array $params): bool => in_array('mon-article', $params, true))); $this->repository->findBySlug('mon-article'); } @@ -316,7 +327,7 @@ final class PostRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 12]); + ->with($this->callback(fn (array $params): bool => in_array(12, $params, true))); $this->repository->findById(12); } @@ -455,7 +466,7 @@ final class PostRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 6]); + ->with($this->callback(fn (array $params): bool => in_array(6, $params, true))); $this->repository->delete(6); } diff --git a/tests/Post/PostServiceCoverageTest.php b/tests/Post/PostServiceCoverageTest.php index 9694a40..bb581fc 100644 --- a/tests/Post/PostServiceCoverageTest.php +++ b/tests/Post/PostServiceCoverageTest.php @@ -5,7 +5,7 @@ namespace Tests\Post; use App\Post\Post; use App\Post\PostRepositoryInterface; -use App\Post\Application\PostApplicationService as PostService; +use App\Post\Application\PostApplicationService; use App\Shared\Html\HtmlSanitizerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -19,13 +19,13 @@ final class PostServiceCoverageTest extends TestCase /** @var HtmlSanitizerInterface&MockObject */ private HtmlSanitizerInterface $sanitizer; - private PostService $service; + private PostApplicationService $service; protected function setUp(): void { $this->repository = $this->createMock(PostRepositoryInterface::class); $this->sanitizer = $this->createMock(HtmlSanitizerInterface::class); - $this->service = new PostService($this->repository, $this->sanitizer); + $this->service = new PostApplicationService($this->repository, $this->sanitizer); } public function testGetAllPostsPassesCategoryIdToRepository(): void diff --git a/tests/Post/PostServiceTest.php b/tests/Post/PostServiceTest.php index 9084c27..262bff9 100644 --- a/tests/Post/PostServiceTest.php +++ b/tests/Post/PostServiceTest.php @@ -5,14 +5,14 @@ namespace Tests\Post; use App\Post\Post; use App\Post\PostRepositoryInterface; -use App\Post\Application\PostApplicationService as PostService; +use App\Post\Application\PostApplicationService; use App\Shared\Exception\NotFoundException; use App\Shared\Html\HtmlSanitizerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour PostService. + * Tests unitaires pour PostApplicationService. * * Couvre la création, la mise à jour, la suppression et les lectures. * HtmlSanitizerInterface et PostRepository sont mockés pour isoler la logique métier. @@ -26,13 +26,13 @@ final class PostServiceTest extends TestCase /** @var HtmlSanitizerInterface&MockObject */ private HtmlSanitizerInterface $sanitizer; - private PostService $service; + private PostApplicationService $service; protected function setUp(): void { $this->repository = $this->createMock(PostRepositoryInterface::class); $this->sanitizer = $this->createMock(HtmlSanitizerInterface::class); - $this->service = new PostService($this->repository, $this->sanitizer); + $this->service = new PostApplicationService($this->repository, $this->sanitizer); } diff --git a/tests/Post/RssControllerTest.php b/tests/Post/RssControllerTest.php index d9ddb45..9886a5e 100644 --- a/tests/Post/RssControllerTest.php +++ b/tests/Post/RssControllerTest.php @@ -5,7 +5,7 @@ namespace Tests\Post; use App\Post\Post; use App\Post\PostServiceInterface; -use App\Post\Http\RssController as RssController; +use App\Post\Http\RssController; use PHPUnit\Framework\MockObject\MockObject; use Tests\ControllerTestBase; diff --git a/tests/User/UserControllerTest.php b/tests/User/UserControllerTest.php index 2be0e1d..8d691b3 100644 --- a/tests/User/UserControllerTest.php +++ b/tests/User/UserControllerTest.php @@ -10,7 +10,7 @@ use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateUsernameException; use App\User\Exception\WeakPasswordException; use App\User\User; -use App\User\Http\UserController as UserController; +use App\User\Http\UserController; use App\User\UserServiceInterface; use PHPUnit\Framework\MockObject\MockObject; use Tests\ControllerTestBase; diff --git a/tests/User/UserRepositoryTest.php b/tests/User/UserRepositoryTest.php index c5d41f0..e7c90e8 100644 --- a/tests/User/UserRepositoryTest.php +++ b/tests/User/UserRepositoryTest.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Tests\User; use App\User\User; -use App\User\Infrastructure\PdoUserRepository as UserRepository; +use App\User\Infrastructure\PdoUserRepository; use PDO; use PDOStatement; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour UserRepository. + * Tests unitaires pour PdoUserRepository. * * Vérifie que chaque méthode du dépôt construit le bon SQL, * lie les bons paramètres et retourne les bonnes valeurs. @@ -25,7 +25,7 @@ final class UserRepositoryTest extends TestCase /** @var PDO&MockObject */ private PDO $db; - private UserRepository $repository; + private PdoUserRepository $repository; /** * Données représentant une ligne utilisateur en base de données. @@ -40,7 +40,7 @@ final class UserRepositoryTest extends TestCase protected function setUp(): void { $this->db = $this->createMock(PDO::class); - $this->repository = new UserRepository($this->db); + $this->repository = new PdoUserRepository($this->db); $this->rowAlice = [ 'id' => 1, @@ -102,18 +102,15 @@ final class UserRepositoryTest extends TestCase } /** - * findAll() doit interroger la table 'users' avec un tri par created_at ASC. + * findAll() interroge bien la table `users`. */ - public function testFindAllQueriesWithAscendingOrder(): void + public function testFindAllRequestsUsersQuery(): void { $stmt = $this->stmtForRead([]); $this->db->expects($this->once()) ->method('query') - ->with($this->logicalAnd( - $this->stringContains('users'), - $this->stringContains('created_at ASC'), - )) + ->with($this->stringContains('FROM users')) ->willReturn($stmt); $this->repository->findAll(); @@ -157,7 +154,7 @@ final class UserRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 42]); + ->with($this->callback(fn (array $params): bool => in_array(42, $params, true))); $this->repository->findById(42); } @@ -200,7 +197,7 @@ final class UserRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':username' => 'alice']); + ->with($this->callback(fn (array $params): bool => in_array('alice', $params, true))); $this->repository->findByUsername('alice'); } @@ -243,7 +240,7 @@ final class UserRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':email' => 'alice@example.com']); + ->with($this->callback(fn (array $params): bool => in_array('alice@example.com', $params, true))); $this->repository->findByEmail('alice@example.com'); } @@ -308,7 +305,7 @@ final class UserRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':password_hash' => $newHash, ':id' => 1]); + ->with($this->callback(fn (array $params): bool => in_array($newHash, $params, true) && in_array(1, $params, true))); $this->repository->updatePassword(1, $newHash); } @@ -329,7 +326,7 @@ final class UserRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':role' => User::ROLE_EDITOR, ':id' => 1]); + ->with($this->callback(fn (array $params): bool => in_array(User::ROLE_EDITOR, $params, true) && in_array(1, $params, true))); $this->repository->updateRole(1, User::ROLE_EDITOR); } @@ -351,7 +348,7 @@ final class UserRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with([':id' => 7]); + ->with($this->callback(fn (array $params): bool => in_array(7, $params, true))); $this->repository->delete(7); } diff --git a/tests/User/UserServiceTest.php b/tests/User/UserServiceTest.php index 83ddf15..4564276 100644 --- a/tests/User/UserServiceTest.php +++ b/tests/User/UserServiceTest.php @@ -9,12 +9,12 @@ use App\User\Exception\InvalidRoleException; use App\User\Exception\WeakPasswordException; use App\User\User; use App\User\UserRepositoryInterface; -use App\User\Application\UserApplicationService as UserService; +use App\User\Application\UserApplicationService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** - * Tests unitaires pour UserService. + * Tests unitaires pour UserApplicationService. * * Vérifie la création de compte : normalisation, unicité du nom d'utilisateur * et de l'email, validation de la complexité du mot de passe. @@ -27,12 +27,12 @@ final class UserServiceTest extends TestCase /** @var UserRepositoryInterface&MockObject */ private UserRepositoryInterface $userRepository; - private UserService $service; + private UserApplicationService $service; protected function setUp(): void { $this->userRepository = $this->createMock(UserRepositoryInterface::class); - $this->service = new UserService($this->userRepository); + $this->service = new UserApplicationService($this->userRepository); }