Refatoring : Working state
This commit is contained in:
@@ -37,18 +37,18 @@ Les tests sont dans `tests/`, organisés en miroir de `src/`.
|
|||||||
|
|
||||||
| Fichier | Classe testée | Ce qui est vérifié |
|
| Fichier | Classe testée | Ce qui est vérifié |
|
||||||
|--------------------------------|---------------------------|---------------------|
|
|--------------------------------|---------------------------|---------------------|
|
||||||
| `AuthServiceTest` | `AuthService` | `createUser()` (normalisation, unicité via exceptions métier, longueur mdp), `authenticate()`, `changePassword()`, `login/logout/isLoggedIn()` |
|
| `AuthServiceTest` | `AuthApplicationService` | `authenticate()`, `changePassword()`, `login/logout/isLoggedIn()` |
|
||||||
| `AuthServiceRateLimitTest` | `AuthService` | `checkRateLimit()` (IP libre, verrouillée, expirée, minimum 1 minute, `deleteExpired()`), `recordFailure()` (constantes MAX_ATTEMPTS/LOCK_MINUTES), `resetRateLimit()` |
|
| `AuthServiceRateLimitTest` | `AuthApplicationService` | `checkRateLimit()` (IP libre, verrouillée, expirée, minimum 1 minute, `deleteExpired()`), `recordFailure()` (constantes MAX_ATTEMPTS/LOCK_MINUTES), `resetRateLimit()` |
|
||||||
| `LoginAttemptRepositoryTest` | `LoginAttemptRepository` | `findByIp()`, `recordFailure()` (INSERT vs UPDATE, compteur, seuil exact, fenêtre temporelle), `resetForIp()`, `deleteExpired()` |
|
| `LoginAttemptRepositoryTest` | `PdoLoginAttemptRepository` | `findByIp()`, `recordFailure()` (INSERT vs UPDATE, compteur, seuil exact, fenêtre temporelle), `resetForIp()`, `deleteExpired()` |
|
||||||
| `PasswordResetServiceTest` | `PasswordResetService` | `requestReset()` (email inconnu silencieux, invalidation, création, envoi, URL), `validateToken()` (inexistant, expiré, valide), `resetPassword()` (token invalide, mdp trop court via `WeakPasswordException`, mise à jour + consommation) |
|
| `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` | `PasswordResetRepository` | `create()`, `findActiveByHash()` (filtre `used_at = null`), `invalidateByUserId()` et `markAsUsed()` (jamais de `delete`) |
|
| `PasswordResetRepositoryTest` | `PdoPasswordResetRepository` | `create()`, `findActiveByHash()` (filtre `used_at = null`), `invalidateByUserId()` et `markAsUsed()` (jamais de `delete`) |
|
||||||
|
|
||||||
### `tests/User/`
|
### `tests/User/`
|
||||||
|
|
||||||
| Fichier | Classe testée | Ce qui est vérifié |
|
| Fichier | Classe testée | Ce qui est vérifié |
|
||||||
|-----------------------|------------------|---------------------|
|
|-----------------------|------------------|---------------------|
|
||||||
| `UserTest` | `User` | Construction, validation (username, email, hash, rôle), limites min/max, `fromArray()` |
|
| `UserTest` | `User` | Construction, validation (username, email, hash, rôle), limites min/max, `fromArray()` |
|
||||||
| `UserRepositoryTest` | `UserRepository` | `findAll/ById/ByUsername/ByEmail()`, `create()`, `updatePassword()`, `delete()` |
|
| `UserRepositoryTest` | `PdoUserRepository` | `findAll/ById/ByUsername/ByEmail()`, `create()`, `updatePassword()`, `delete()` |
|
||||||
|
|
||||||
### `tests/Shared/`
|
### `tests/Shared/`
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Les tests sont dans `tests/`, organisés en miroir de `src/`.
|
|||||||
|
|
||||||
### Mocks
|
### Mocks
|
||||||
|
|
||||||
Les services métier (`AuthService`, `PasswordResetService`, `PostService`) utilisent les **interfaces** comme type des dépendances. Mocker l'interface plutôt que la classe concrète :
|
Les services applicatifs (`AuthApplicationService`, `PasswordResetApplicationService`, `PostApplicationService`) utilisent les **interfaces** comme type des dépendances. Mocker l'interface plutôt que la classe concrète :
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// ✅ Correct
|
// ✅ Correct
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
Blog multi-utilisateurs modulaire développé avec Slim 4. Les domaines `Auth`, `Category`, `Media`, `User`
|
Blog multi-utilisateurs modulaire développé avec Slim 4. Les domaines `Auth`, `Post`, `Category`, `Media`, `User`
|
||||||
et `Shared` portent une architecture DDD légère, lisible et réutilisable pour d'autres
|
et `Shared` portent une architecture DDD légère, lisible et réutilisable pour d'autres
|
||||||
projets (boutique, portfolio…).
|
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`.
|
Le provisionnement (migrations + seed admin) s'exécute explicitement via `php bin/provision.php`.
|
||||||
|
|
||||||
- Développement local : exécuter `php bin/provision.php` apres `cp .env.example .env`
|
- 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` apres le demarrage du conteneur
|
- Docker / production : exécuter `docker compose exec app php bin/provision.php` après 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.
|
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 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`.
|
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`.
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ $id = 42;
|
|||||||
?int $authorId = null;
|
?int $authorId = null;
|
||||||
|
|
||||||
// Signature typée complète — paramètres et valeur de retour
|
// Signature typée complète — paramètres et valeur de retour
|
||||||
// Extrait de PostService
|
// Extrait de PostApplicationService
|
||||||
public function createPost(string $title, string $content,
|
public function createPost(string $title, string $content,
|
||||||
int $authorId, ?int $categoryId = null): int
|
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 `PostService::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 `PostApplicationService::generateUniqueSlug()` pour tester des variantes jusqu'à trouver un slug libre :
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$slug = $baseSlug;
|
$slug = $baseSlug;
|
||||||
@@ -310,7 +310,7 @@ final class NotFoundException extends \RuntimeException
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utilisation dans PostService
|
// Utilisation dans PostApplicationService
|
||||||
$post = $this->postRepository->findById($id);
|
$post = $this->postRepository->findById($id);
|
||||||
if ($post === null) {
|
if ($post === null) {
|
||||||
throw new NotFoundException('Article', $id); // → HTTP 404
|
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 :
|
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.
|
- **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. `PostService` ne change pas d'une ligne.
|
- **Interchangeabilité** — passer de SQLite à PostgreSQL ne nécessite qu'une nouvelle implémentation de l'interface. `PostApplicationService` ne change pas d'une ligne.
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
#### 2.2.3 Injection de dépendances
|
#### 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).
|
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
|
```php
|
||||||
// ❌ Couplage fort — PostService crée lui-même ses dépendances
|
// ❌ Couplage fort — un service applicatif crée lui-même ses dépendances
|
||||||
// Impossible à tester : on ne peut pas substituer un faux repository.
|
// Impossible à tester : on ne peut pas substituer un faux repository.
|
||||||
class PostService {
|
class PostApplicationService {
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
$this->repo = new PostRepository();
|
$this->repo = new PostRepository();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Injection — les dépendances sont fournies de l'extérieur
|
// ✅ Injection — les dépendances sont fournies de l'extérieur
|
||||||
// Extrait réel de src/Post/PostService.php
|
// Extrait réel de src/Post/Application/PostApplicationService.php
|
||||||
final class PostService
|
final class PostApplicationService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PostRepositoryInterface $postRepository,
|
private readonly PostRepositoryInterface $postRepository,
|
||||||
@@ -431,7 +431,7 @@ Qui assemble les dépendances ? PHP-DI dans `config/container.php`. Il résout a
|
|||||||
```php
|
```php
|
||||||
// Extrait de config/container.php
|
// Extrait de config/container.php
|
||||||
// Binding interface → implémentation : PHP-DI injecte PostRepository partout où
|
// Binding interface → implémentation : PHP-DI injecte PostRepository partout où
|
||||||
// PostRepositoryInterface est demandé. PostService lui-même est résolu par autowiring.
|
// PostRepositoryInterface est demandé. PostApplicationService lui-même est résolu par autowiring.
|
||||||
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
||||||
PostServiceInterface::class => autowire(PostApplicationService::class),
|
PostServiceInterface::class => autowire(PostApplicationService::class),
|
||||||
```
|
```
|
||||||
@@ -579,7 +579,7 @@ database/ ← migrations SQL, une par table
|
|||||||
├── 003_create_posts.php
|
├── 003_create_posts.php
|
||||||
├── 004_create_media.php
|
├── 004_create_media.php
|
||||||
├── 005_create_password_resets.php
|
├── 005_create_password_resets.php
|
||||||
├── 006_create_posts_fts.php
|
├── 002_create_posts_search.php
|
||||||
└── 007_create_login_attempts.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. |
|
| 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. |
|
| PostRepositoryInterface.php | Contrat : liste les méthodes de persistance sans les implémenter. |
|
||||||
| PostRepository.php | Implémentation PDO : requêtes SQL réelles. |
|
| PostRepository.php | Implémentation PDO : requêtes SQL réelles. |
|
||||||
| PostService.php | Logique métier : création d'un slug, validation, appel du repository. |
|
| PostApplicationService.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. |
|
| 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. |
|
| 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. |
|
| 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 `PostService`, mais `PostService` ne connaît que `PostRepositoryInterface` — jamais `PostRepository` directement. `PostRepository`, 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 `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.
|
||||||
|
|
||||||
```
|
```
|
||||||
PostController
|
PostController
|
||||||
@@ -795,7 +795,7 @@ PostRepositoryInterface ← implémente — PostRepository
|
|||||||
PDO (SQLite)
|
PDO (SQLite)
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 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.
|
> 💡 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.
|
||||||
|
|
||||||
### 5.4 Le flux d'une requête
|
### 5.4 Le flux d'une requête
|
||||||
|
|
||||||
@@ -818,7 +818,7 @@ Routeur Slim
|
|||||||
PostController::create()
|
PostController::create()
|
||||||
│ extrait et valide les données POST
|
│ extrait et valide les données POST
|
||||||
▼
|
▼
|
||||||
PostService::createPost()
|
PostApplicationService::createPost()
|
||||||
│ sanitise le HTML (HTMLPurifier)
|
│ sanitise le HTML (HTMLPurifier)
|
||||||
│ génère un slug unique
|
│ génère un slug unique
|
||||||
▼
|
▼
|
||||||
@@ -901,9 +901,9 @@ Il contient treize fichiers PHP dans `src/Auth/` :
|
|||||||
|
|
||||||
```
|
```
|
||||||
-- Services --
|
-- Services --
|
||||||
AuthService.php — connexion, sessions, vérification des rôles
|
AuthApplicationService.php — connexion, sessions, vérification des rôles
|
||||||
AuthServiceInterface.php — contrat du service d'authentification
|
AuthServiceInterface.php — contrat du service d'authentification
|
||||||
PasswordResetService.php — génération du token, envoi e-mail, validation
|
PasswordResetApplicationService.php — génération du token, envoi e-mail, validation
|
||||||
PasswordResetRepositoryInterface.php
|
PasswordResetRepositoryInterface.php
|
||||||
PasswordResetRepository.php — persistance des tokens de réinitialisation
|
PasswordResetRepository.php — persistance des tokens de réinitialisation
|
||||||
LoginAttemptRepositoryInterface.php
|
LoginAttemptRepositoryInterface.php
|
||||||
@@ -948,7 +948,7 @@ $this->authService->resetRateLimit($ip);
|
|||||||
$this->authService->login($user); // écrit userId/username/role en session
|
$this->authService->login($user); // écrit userId/username/role en session
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 `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.
|
> 💡 `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.
|
||||||
>
|
>
|
||||||
> ⚠️ 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`.
|
> ⚠️ 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).
|
`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 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.
|
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.
|
||||||
|
|
||||||
`strip_tags()` est disponible dans ce contexte car `sqliteCreateFunction()` est appelé dans `container.php` avant `Migrator::run()` dans la séquence de démarrage.
|
`strip_tags()` est disponible dans ce contexte car `sqliteCreateFunction()` est appelé dans `container.php` avant `Migrator::run()` dans la séquence de démarrage.
|
||||||
|
|
||||||
`PasswordResetService` gère le cycle complet en trois étapes :
|
`PasswordResetApplicationService` 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.
|
- `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`.
|
- `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
|
### 6.2 Migrations
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
> 💡 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.
|
> 💡 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
|
#### 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 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.
|
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`.
|
||||||
|
|
||||||
> ⚠️ Ne jamais supprimer la base en production. Créer une nouvelle migration à la place.
|
> ⚠️ Ne jamais supprimer la base en production. Créer une nouvelle migration à la place.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use App\User\Exception\WeakPasswordException;
|
|||||||
use App\User\User;
|
use App\User\User;
|
||||||
use App\User\UserRepositoryInterface;
|
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
|
class AuthApplicationService implements AuthServiceInterface
|
||||||
{
|
{
|
||||||
private readonly LoginRateLimitPolicy $rateLimitPolicy;
|
private readonly LoginRateLimitPolicy $rateLimitPolicy;
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ use App\User\User;
|
|||||||
use App\User\UserRepositoryInterface;
|
use App\User\UserRepositoryInterface;
|
||||||
use PDO;
|
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
|
class PasswordResetApplicationService implements PasswordResetServiceInterface
|
||||||
{
|
{
|
||||||
private readonly PasswordResetTokenPolicy $tokenPolicy;
|
private readonly PasswordResetTokenPolicy $tokenPolicy;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use App\User\User;
|
|||||||
* Contrat du service d'authentification.
|
* Contrat du service d'authentification.
|
||||||
*
|
*
|
||||||
* Permet de mocker le service dans les tests unitaires sans dépendre
|
* Permet de mocker le service dans les tests unitaires sans dépendre
|
||||||
* de la classe concrète finale AuthService.
|
* de la classe concrète finale AuthApplicationService.
|
||||||
*/
|
*/
|
||||||
interface AuthServiceInterface
|
interface AuthServiceInterface
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class AccountController
|
|||||||
*
|
*
|
||||||
* Vérifie que les deux nouveaux mots de passe sont identiques,
|
* 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
|
* puis délègue la vérification du mot de passe actuel et la mise
|
||||||
* à jour à AuthService.
|
* à jour à AuthApplicationService.
|
||||||
*
|
*
|
||||||
* Note : getUserId() ne peut pas retourner null ici car la route
|
* Note : getUserId() ne peut pas retourner null ici car la route
|
||||||
* est protégée par AuthMiddleware. La valeur de repli 0 ne sera
|
* est protégée par AuthMiddleware. La valeur de repli 0 ne sera
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace App\Auth;
|
|||||||
/**
|
/**
|
||||||
* Contrat de persistance des tentatives de connexion.
|
* Contrat de persistance des tentatives de connexion.
|
||||||
*
|
*
|
||||||
* Découple AuthService de l'implémentation concrète PDO/SQLite,
|
* Découple AuthApplicationService de l'implémentation concrète PDO/SQLite,
|
||||||
* facilitant les mocks dans les tests unitaires.
|
* facilitant les mocks dans les tests unitaires.
|
||||||
*/
|
*/
|
||||||
interface LoginAttemptRepositoryInterface
|
interface LoginAttemptRepositoryInterface
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ use App\User\User;
|
|||||||
* 2. Validation du token reçu par e-mail
|
* 2. Validation du token reçu par e-mail
|
||||||
* 3. Réinitialisation effective du mot de passe
|
* 3. Réinitialisation effective du mot de passe
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Contrat applicatif du flux de réinitialisation de mot de passe.
|
||||||
|
*/
|
||||||
interface PasswordResetServiceInterface
|
interface PasswordResetServiceInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ use App\Category\CategoryServiceInterface;
|
|||||||
use App\Category\Domain\CategorySlugGenerator;
|
use App\Category\Domain\CategorySlugGenerator;
|
||||||
use App\Shared\Pagination\PaginatedResult;
|
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
|
class CategoryApplicationService implements CategoryServiceInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Category;
|
namespace App\Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat de persistance du domaine Category.
|
||||||
|
*/
|
||||||
interface CategoryRepositoryInterface
|
interface CategoryRepositoryInterface
|
||||||
{
|
{
|
||||||
/** @return Category[] */
|
/** @return Category[] */
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ namespace App\Category;
|
|||||||
|
|
||||||
use App\Shared\Pagination\PaginatedResult;
|
use App\Shared\Pagination\PaginatedResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat applicatif du domaine Category.
|
||||||
|
*/
|
||||||
interface CategoryServiceInterface
|
interface CategoryServiceInterface
|
||||||
{
|
{
|
||||||
/** @return Category[] */
|
/** @return Category[] */
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use App\Shared\Pagination\PaginatedResult;
|
|||||||
use PDOException;
|
use PDOException;
|
||||||
use Psr\Http\Message\UploadedFileInterface;
|
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
|
class MediaApplicationService implements MediaServiceInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ use Psr\Http\Message\ResponseInterface as Response;
|
|||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
use Slim\Views\Twig;
|
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
|
class MediaController
|
||||||
{
|
{
|
||||||
private const PER_PAGE = 12;
|
private const PER_PAGE = 12;
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ use App\Media\Media;
|
|||||||
use App\Media\MediaRepositoryInterface;
|
use App\Media\MediaRepositoryInterface;
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implémentation PDO du repository Media.
|
||||||
|
*/
|
||||||
class PdoMediaRepository implements MediaRepositoryInterface
|
class PdoMediaRepository implements MediaRepositoryInterface
|
||||||
{
|
{
|
||||||
private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media';
|
private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media';
|
||||||
@@ -85,15 +88,6 @@ class PdoMediaRepository implements MediaRepositoryInterface
|
|||||||
return $row ? Media::fromArray($row) : null;
|
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
|
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');
|
$stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash AND user_id = :user_id ORDER BY id DESC LIMIT 1');
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Media;
|
namespace App\Media;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat de persistance du domaine Media.
|
||||||
|
*/
|
||||||
interface MediaRepositoryInterface
|
interface MediaRepositoryInterface
|
||||||
{
|
{
|
||||||
/** @return Media[] */
|
/** @return Media[] */
|
||||||
@@ -23,7 +26,6 @@ interface MediaRepositoryInterface
|
|||||||
|
|
||||||
public function findById(int $id): ?Media;
|
public function findById(int $id): ?Media;
|
||||||
|
|
||||||
public function findByHash(string $hash): ?Media;
|
|
||||||
|
|
||||||
public function findByHashForUser(string $hash, int $userId): ?Media;
|
public function findByHashForUser(string $hash, int $userId): ?Media;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ namespace App\Media;
|
|||||||
use App\Shared\Pagination\PaginatedResult;
|
use App\Shared\Pagination\PaginatedResult;
|
||||||
use Psr\Http\Message\UploadedFileInterface;
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat applicatif du domaine Media.
|
||||||
|
*/
|
||||||
interface MediaServiceInterface
|
interface MediaServiceInterface
|
||||||
{
|
{
|
||||||
/** @return Media[] */
|
/** @return Media[] */
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ use App\Shared\Exception\NotFoundException;
|
|||||||
use App\Shared\Html\HtmlSanitizerInterface;
|
use App\Shared\Html\HtmlSanitizerInterface;
|
||||||
use App\Shared\Pagination\PaginatedResult;
|
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
|
class PostApplicationService implements PostServiceInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Post;
|
namespace App\Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat de persistance du domaine Post.
|
||||||
|
*/
|
||||||
interface PostRepositoryInterface
|
interface PostRepositoryInterface
|
||||||
{
|
{
|
||||||
/** @return Post[] */
|
/** @return Post[] */
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ namespace App\Post;
|
|||||||
use App\Shared\Exception\NotFoundException;
|
use App\Shared\Exception\NotFoundException;
|
||||||
use App\Shared\Pagination\PaginatedResult;
|
use App\Shared\Pagination\PaginatedResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat applicatif du domaine Post.
|
||||||
|
*/
|
||||||
interface PostServiceInterface
|
interface PostServiceInterface
|
||||||
{
|
{
|
||||||
/** @return Post[] */
|
/** @return Post[] */
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ final class Migrator
|
|||||||
*
|
*
|
||||||
* Nécessaire car les triggers FTS5 ne couvrent que les INSERT/UPDATE/DELETE
|
* 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
|
* effectués APRÈS leur création — les articles existants au moment de la
|
||||||
* migration 006 ne sont pas indexés rétroactivement.
|
* migration 002 ne sont pas indexés rétroactivement.
|
||||||
*
|
*
|
||||||
* strip_tags() est enregistrée comme fonction SQLite dans container.php via
|
* strip_tags() est enregistrée comme fonction SQLite dans container.php via
|
||||||
* sqliteCreateFunction() avant l'appel à Migrator::run() — elle est donc
|
* sqliteCreateFunction() avant l'appel à Migrator::run() — elle est donc
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ use App\User\User;
|
|||||||
use App\User\UserRepositoryInterface;
|
use App\User\UserRepositoryInterface;
|
||||||
use App\User\UserServiceInterface;
|
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
|
class UserApplicationService implements UserServiceInterface
|
||||||
{
|
{
|
||||||
private readonly RolePolicy $rolePolicy;
|
private readonly RolePolicy $rolePolicy;
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\User;
|
namespace App\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat de persistance du domaine User.
|
||||||
|
*/
|
||||||
interface UserRepositoryInterface
|
interface UserRepositoryInterface
|
||||||
{
|
{
|
||||||
/** @return User[] */
|
/** @return User[] */
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ use App\User\Exception\DuplicateUsernameException;
|
|||||||
use App\User\Exception\InvalidRoleException;
|
use App\User\Exception\InvalidRoleException;
|
||||||
use App\User\Exception\WeakPasswordException;
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat applicatif du domaine User.
|
||||||
|
*/
|
||||||
interface UserServiceInterface
|
interface UserServiceInterface
|
||||||
{
|
{
|
||||||
/** @return User[] */
|
/** @return User[] */
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ final class MediaRepositoryTest extends TestCase
|
|||||||
return $stmt;
|
return $stmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── findAll ────────────────────────────────────────────────────
|
// ── findAll ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +117,6 @@ final class MediaRepositoryTest extends TestCase
|
|||||||
$this->repository->findAll();
|
$this->repository->findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── findByUserId ───────────────────────────────────────────────
|
// ── findByUserId ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +159,6 @@ final class MediaRepositoryTest extends TestCase
|
|||||||
$this->repository->findByUserId(5);
|
$this->repository->findByUserId(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── findById ───────────────────────────────────────────────────
|
// ── findById ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,53 +201,8 @@ final class MediaRepositoryTest extends TestCase
|
|||||||
$this->repository->findById(8);
|
$this->repository->findById(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── findByHash ─────────────────────────────────────────────────
|
// ── 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([':hash' => $hash]);
|
|
||||||
|
|
||||||
$this->repository->findByHash($hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── create ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* create() prépare un INSERT avec les bonnes colonnes.
|
* create() prépare un INSERT avec les bonnes colonnes.
|
||||||
*/
|
*/
|
||||||
@@ -291,7 +243,6 @@ final class MediaRepositoryTest extends TestCase
|
|||||||
$this->assertSame(15, $this->repository->create($media));
|
$this->assertSame(15, $this->repository->create($media));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── delete ─────────────────────────────────────────────────────
|
// ── delete ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user