diff --git a/.env.example b/.env.example index cd2112f..d183e98 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,5 @@ MAIL_FROM_NAME="Slim Blog" # Taille maximale en octets (doit être < upload_max_filesize dans docker/php/php.ini en production) UPLOAD_MAX_SIZE=5242880 + +SESSION_NAME=slim_blog_session diff --git a/.gitignore b/.gitignore index f1b8e9a..f7ea1d3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,6 @@ public/assets/ database/*.sqlite database/*.sqlite-shm database/*.sqlite-wal -database/.provision.lock # ============================================ # Cache & Logs diff --git a/README.md b/README.md index fcc2480..974dfe0 100644 --- a/README.md +++ b/README.md @@ -183,3 +183,5 @@ Le provisionnement (migrations + seed admin) s'execute explicitement via `php bi - Docker / production : executer `docker compose exec app php bin/provision.php` apres le demarrage du conteneur Le runtime HTTP ne provisionne plus automatiquement la base. Si le schema n'est pas present, l'application echoue avec un message explicite demandant d'executer la commande de provisionnement. + +Pour repartir d'un schema frais en developpement 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/config/container.php b/config/container.php index 97dd73c..8540f15 100644 --- a/config/container.php +++ b/config/container.php @@ -144,9 +144,10 @@ return [ }), MediaServiceInterface::class => factory( - function (MediaRepositoryInterface $mediaRepository): MediaServiceInterface { + function (MediaRepositoryInterface $mediaRepository, PostRepositoryInterface $postRepository): MediaServiceInterface { return new MediaService( mediaRepository: $mediaRepository, + postRepository: $postRepository, uploadDir: dirname(__DIR__) . '/public/media', uploadUrl: '/media', maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024), diff --git a/database/.provision.lock b/database/.provision.lock deleted file mode 100644 index e69de29..0000000 diff --git a/database/migrations/004_create_media.php b/database/migrations/004_create_media.php index f8136d1..4dc0a11 100644 --- a/database/migrations/004_create_media.php +++ b/database/migrations/004_create_media.php @@ -1,28 +1,21 @@ " CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, url TEXT NOT NULL, - hash TEXT NOT NULL UNIQUE, + hash TEXT NOT NULL, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id); + CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id); ", 'down' => " + DROP INDEX IF EXISTS idx_media_hash_user_id; DROP INDEX IF EXISTS idx_media_user_id; DROP TABLE IF EXISTS media; ", diff --git a/phpunit.xml b/phpunit.xml index 963fe00..43a43f0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -4,6 +4,8 @@ bootstrap="vendor/autoload.php" colors="true" displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnPhpunitDeprecations="true" + displayDetailsOnPhpunitNotices="true" displayDetailsOnTestsThatTriggerErrors="true" displayDetailsOnTestsThatTriggerNotices="true" displayDetailsOnTestsThatTriggerWarnings="true"> diff --git a/public/index.php b/public/index.php index de868e2..a46c675 100644 --- a/public/index.php +++ b/public/index.php @@ -10,11 +10,20 @@ $bootstrap = Bootstrap::create(); $bootstrap->initializeInfrastructure(); $trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER); +$sessionName = trim((string) ($_ENV['SESSION_NAME'] ?? 'slim_blog_session')) ?: 'slim_blog_session'; + +ini_set('session.use_strict_mode', '1'); +ini_set('session.use_only_cookies', '1'); +ini_set('session.cookie_httponly', '1'); +ini_set('session.cookie_samesite', 'Lax'); +session_name($sessionName); session_start([ 'cookie_secure' => RequestContext::isHttps($_SERVER, $trustedProxies), 'cookie_httponly' => true, 'cookie_samesite' => 'Lax', + 'cookie_lifetime' => 0, + 'use_strict_mode' => 1, ]); $app = $bootstrap->createHttpApp(); diff --git a/src/Category/CategoryController.php b/src/Category/CategoryController.php index 8e6f5c7..d2f386e 100644 --- a/src/Category/CategoryController.php +++ b/src/Category/CategoryController.php @@ -4,25 +4,15 @@ declare(strict_types=1); namespace App\Category; use App\Shared\Http\FlashServiceInterface; +use App\Shared\Pagination\PaginationPresenter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Views\Twig; -/** - * Contrôleur pour la gestion des catégories. - * - * Accessible aux éditeurs et administrateurs (protégé par EditorMiddleware). - * Gère la liste des catégories, leur création et leur suppression. - * Toute la logique métier (génération de slug, validations, blocage de - * suppression) est déléguée à CategoryService. - */ final class CategoryController { - /** - * @param Twig $view Moteur de templates Twig - * @param CategoryServiceInterface $categoryService Service de gestion des catégories - * @param FlashServiceInterface $flash Service de messages flash - */ + private const PER_PAGE = 20; + public function __construct( private readonly Twig $view, private readonly CategoryServiceInterface $categoryService, @@ -30,37 +20,21 @@ final class CategoryController ) { } - /** - * Affiche la liste des catégories avec le formulaire de création. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response La vue de gestion des catégories - */ public function index(Request $req, Response $res): Response { + $page = PaginationPresenter::resolvePage($req->getQueryParams()); + $paginated = $this->categoryService->findPaginated($page, self::PER_PAGE); + return $this->view->render($res, 'admin/categories/index.twig', [ - 'categories' => $this->categoryService->findAll(), + 'categories' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), 'error' => $this->flash->get('category_error'), 'success' => $this->flash->get('category_success'), ]); } - /** - * Traite la création d'une catégorie. - * - * Délègue entièrement à CategoryService::create() qui gère la génération - * du slug, la validation d'unicité et la validation du modèle. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response Une redirection vers /admin/categories - */ public function create(Request $req, Response $res): Response { - /** @var array $data */ $data = (array) $req->getParsedBody(); $name = (string) ($data['name'] ?? ''); @@ -78,16 +52,7 @@ final class CategoryController } /** - * Supprime une catégorie. - * - * Délègue à CategoryService::delete() qui refuse la suppression si des - * articles sont rattachés à la catégorie. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * @param array $args Paramètres de route (id) - * - * @return Response Une redirection vers /admin/categories + * @param array $args */ public function delete(Request $req, Response $res, array $args): Response { @@ -96,7 +61,6 @@ final class CategoryController if ($category === null) { $this->flash->set('category_error', 'Catégorie introuvable'); - return $res->withHeader('Location', '/admin/categories')->withStatus(302); } diff --git a/src/Category/CategoryRepository.php b/src/Category/CategoryRepository.php index c5607c4..b626421 100644 --- a/src/Category/CategoryRepository.php +++ b/src/Category/CategoryRepository.php @@ -5,78 +5,60 @@ namespace App\Category; use PDO; -/** - * Dépôt pour la persistance des catégories. - * - * Responsabilité unique : exécuter les requêtes SQL liées à la table `categories` - * et retourner des instances de Category hydratées. - */ final class CategoryRepository implements CategoryRepositoryInterface { - /** - * @param PDO $db Instance de connexion à la base de données - */ public function __construct(private readonly PDO $db) { } - /** - * Retourne toutes les catégories triées alphabétiquement. - * - * @return Category[] La liste des catégories - */ public function findAll(): array { $stmt = $this->db->query('SELECT * FROM categories ORDER BY name ASC'); if ($stmt === false) { throw new \RuntimeException('La requête SELECT sur categories a échoué.'); } - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - return array_map(fn ($row) => Category::fromArray($row), $rows); + return array_map(fn ($row) => Category::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function findPage(int $limit, int $offset): array + { + $stmt = $this->db->prepare('SELECT * FROM categories ORDER BY name ASC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => Category::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countAll(): int + { + $stmt = $this->db->query('SELECT COUNT(*) FROM categories'); + if ($stmt === false) { + throw new \RuntimeException('La requête COUNT sur categories a échoué.'); + } + + return (int) ($stmt->fetchColumn() ?: 0); } - /** - * Trouve une catégorie par son identifiant. - * - * @param int $id Identifiant de la catégorie - * - * @return Category|null La catégorie trouvée, ou null si elle n'existe pas - */ public function findById(int $id): ?Category { $stmt = $this->db->prepare('SELECT * FROM categories WHERE id = :id'); $stmt->execute([':id' => $id]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? Category::fromArray($row) : null; } - /** - * Trouve une catégorie par son slug URL. - * - * @param string $slug Le slug de la catégorie - * - * @return Category|null La catégorie trouvée, ou null si elle n'existe pas - */ public function findBySlug(string $slug): ?Category { $stmt = $this->db->prepare('SELECT * FROM categories WHERE slug = :slug'); $stmt->execute([':slug' => $slug]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? Category::fromArray($row) : null; } - /** - * Persiste une nouvelle catégorie en base de données. - * - * @param Category $category La catégorie à créer - * - * @return int L'identifiant généré par la base de données - */ public function create(Category $category): int { $stmt = $this->db->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)'); @@ -85,13 +67,6 @@ final class CategoryRepository implements CategoryRepositoryInterface return (int) $this->db->lastInsertId(); } - /** - * Supprime une catégorie de la base de données. - * - * @param int $id Identifiant de la catégorie à supprimer - * - * @return int Nombre de lignes supprimées (0 si la catégorie n'existe plus) - */ public function delete(int $id): int { $stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id'); @@ -100,13 +75,6 @@ final class CategoryRepository implements CategoryRepositoryInterface return $stmt->rowCount(); } - /** - * Vérifie si un nom est déjà utilisé par une catégorie existante. - * - * @param string $name Le nom à vérifier - * - * @return bool True si le nom est déjà pris - */ public function nameExists(string $name): bool { $stmt = $this->db->prepare('SELECT 1 FROM categories WHERE name = :name'); @@ -115,15 +83,6 @@ final class CategoryRepository implements CategoryRepositoryInterface return $stmt->fetchColumn() !== false; } - /** - * Vérifie si au moins un article est rattaché à cette catégorie. - * - * Utilisé avant suppression pour bloquer la suppression d'une catégorie non vide. - * - * @param int $id Identifiant de la catégorie - * - * @return bool True si au moins un article référence cette catégorie - */ public function hasPost(int $id): bool { $stmt = $this->db->prepare('SELECT 1 FROM posts WHERE category_id = :id'); diff --git a/src/Category/CategoryRepositoryInterface.php b/src/Category/CategoryRepositoryInterface.php index 7714f62..726d5fe 100644 --- a/src/Category/CategoryRepositoryInterface.php +++ b/src/Category/CategoryRepositoryInterface.php @@ -3,72 +3,25 @@ declare(strict_types=1); namespace App\Category; -/** - * Contrat de persistance des catégories. - * - * Découple les services et contrôleurs de l'implémentation concrète PDO/SQLite, - * facilitant les mocks dans les tests unitaires. - */ interface CategoryRepositoryInterface { - /** - * Retourne toutes les catégories triées alphabétiquement. - * - * @return Category[] - */ + /** @return Category[] */ public function findAll(): array; - /** - * Trouve une catégorie par son identifiant. - * - * @param int $id Identifiant de la catégorie - * - * @return Category|null La catégorie trouvée, ou null si elle n'existe pas - */ + /** @return Category[] */ + public function findPage(int $limit, int $offset): array; + + public function countAll(): int; + public function findById(int $id): ?Category; - /** - * Trouve une catégorie par son slug URL. - * - * @param string $slug Le slug de la catégorie - * - * @return Category|null La catégorie trouvée, ou null si elle n'existe pas - */ public function findBySlug(string $slug): ?Category; - /** - * Persiste une nouvelle catégorie en base de données. - * - * @param Category $category La catégorie à créer - * - * @return int L'identifiant généré par la base de données - */ public function create(Category $category): int; - /** - * Supprime une catégorie de la base de données. - * - * @param int $id Identifiant de la catégorie à supprimer - * - * @return int Nombre de lignes supprimées - */ public function delete(int $id): int; - /** - * Vérifie si un nom est déjà utilisé par une catégorie existante. - * - * @param string $name Le nom à vérifier - * - * @return bool True si le nom est déjà pris - */ public function nameExists(string $name): bool; - /** - * Vérifie si au moins un article est rattaché à cette catégorie. - * - * @param int $id Identifiant de la catégorie - * - * @return bool True si au moins un article référence cette catégorie - */ public function hasPost(int $id): bool; } diff --git a/src/Category/CategoryService.php b/src/Category/CategoryService.php index b09bddc..3bfd4ba 100644 --- a/src/Category/CategoryService.php +++ b/src/Category/CategoryService.php @@ -3,82 +3,48 @@ declare(strict_types=1); namespace App\Category; +use App\Shared\Pagination\PaginatedResult; use App\Shared\Util\SlugHelper; -/** - * Service de gestion des catégories. - * - * Centralise la logique métier liée aux catégories : - * - génération et validation du slug à la création - * - vérification d'unicité du nom - * - blocage de la suppression si des articles sont rattachés - * - * Les lectures (findAll, findById, findBySlug) sont exposées ici - * pour que CategoryController et PostController n'injectent pas - * directement le repository — cohérent avec le pattern des autres domaines. - */ final class CategoryService implements CategoryServiceInterface { - /** - * @param CategoryRepositoryInterface $categoryRepository Dépôt de persistance des catégories - */ public function __construct( private readonly CategoryRepositoryInterface $categoryRepository, ) { } - /** - * Retourne toutes les catégories triées alphabétiquement. - * - * @return Category[] - */ public function findAll(): array { return $this->categoryRepository->findAll(); } /** - * Trouve une catégorie par son identifiant. - * - * @param int $id Identifiant de la catégorie - * - * @return Category|null La catégorie trouvée, ou null si elle n'existe pas + * @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult + { + $page = max(1, $page); + $total = $this->categoryRepository->countAll(); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->categoryRepository->findPage($perPage, $offset), + $total, + $page, + $perPage, + ); + } + public function findById(int $id): ?Category { return $this->categoryRepository->findById($id); } - /** - * Trouve une catégorie par son slug URL. - * - * @param string $slug Le slug de la catégorie - * - * @return Category|null La catégorie trouvée, ou null si elle n'existe pas - */ public function findBySlug(string $slug): ?Category { return $this->categoryRepository->findBySlug($slug); } - /** - * Crée une catégorie depuis un nom brut. - * - * Séquence : - * 1. Trim du nom - * 2. Génération du slug via SlugHelper - * 3. Rejet si le slug est vide (nom sans caractère ASCII exploitable) - * 4. Rejet si le nom est déjà utilisé - * 5. Construction du modèle (déclenche la validation longueur/vide) - * 6. Persistance - * - * @param string $name Nom brut de la catégorie (non encore trimmé) - * - * @return int L'identifiant de la catégorie créée - * - * @throws \InvalidArgumentException Si le slug est vide, le nom déjà pris, - * ou si la validation du modèle échoue - */ public function create(string $name): int { $name = trim($name); @@ -92,21 +58,9 @@ final class CategoryService implements CategoryServiceInterface throw new \InvalidArgumentException('Ce nom de catégorie est déjà utilisé'); } - // Le constructeur de Category valide le nom (vide, longueur max) return $this->categoryRepository->create(new Category(0, $name, $slug)); } - /** - * Supprime une catégorie. - * - * Refuse la suppression si au moins un article est rattaché à la catégorie, - * afin d'éviter des articles sans catégorie de façon involontaire. - * - * @param Category $category La catégorie à supprimer - * - * @throws \InvalidArgumentException Si la catégorie contient des articles - * @return void - */ public function delete(Category $category): void { if ($this->categoryRepository->hasPost($category->getId())) { diff --git a/src/Category/CategoryServiceInterface.php b/src/Category/CategoryServiceInterface.php index 434f3cd..7e81252 100644 --- a/src/Category/CategoryServiceInterface.php +++ b/src/Category/CategoryServiceInterface.php @@ -3,62 +3,23 @@ declare(strict_types=1); namespace App\Category; -/** - * Contrat du service de gestion des catégories. - * - * Permet de mocker le service dans les tests unitaires sans dépendre - * de la classe concrète finale CategoryService. - */ +use App\Shared\Pagination\PaginatedResult; + interface CategoryServiceInterface { - /** - * Retourne toutes les catégories triées alphabétiquement. - * - * @return Category[] - */ + /** @return Category[] */ public function findAll(): array; /** - * Trouve une catégorie par son identifiant. - * - * @param int $id Identifiant de la catégorie - * - * @return Category|null La catégorie trouvée, ou null si elle n'existe pas + * @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult; + public function findById(int $id): ?Category; - /** - * Trouve une catégorie par son slug URL. - * - * @param string $slug Le slug de la catégorie - * - * @return Category|null La catégorie trouvée, ou null si elle n'existe pas - */ public function findBySlug(string $slug): ?Category; - /** - * Crée une catégorie depuis un nom brut. - * - * Génère le slug, valide l'unicité du nom et délègue la construction - * du modèle au constructeur de Category (qui valide taille et contenu). - * - * @param string $name Nom brut de la catégorie (non encore trimmé) - * - * @return int L'identifiant de la catégorie créée - * - * @throws \InvalidArgumentException Si le nom produit un slug vide ou est déjà utilisé - */ public function create(string $name): int; - /** - * Supprime une catégorie. - * - * Refuse la suppression si des articles sont rattachés à la catégorie. - * - * @param Category $category La catégorie à supprimer - * - * @throws \InvalidArgumentException Si la catégorie contient des articles - * @return void - */ public function delete(Category $category): void; } diff --git a/src/Media/MediaController.php b/src/Media/MediaController.php index dcca40e..664c515 100644 --- a/src/Media/MediaController.php +++ b/src/Media/MediaController.php @@ -6,37 +6,17 @@ namespace App\Media; use App\Media\Exception\FileTooLargeException; use App\Media\Exception\InvalidMimeTypeException; use App\Media\Exception\StorageException; -use App\Media\MediaServiceInterface; use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; +use App\Shared\Pagination\PaginationPresenter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Views\Twig; -/** - * Contrôleur du domaine Media. - * - * Gère deux responsabilités HTTP : - * 1. Upload d'images depuis l'éditeur Trumbowyg (réponse JSON) - * 2. Administration des médias uploadés (liste, suppression) - * - * Toute la logique métier (validation, conversion WebP, déduplication, - * stockage disque) est déléguée à MediaService via MediaServiceInterface. - * - * Droits d'accès : - * - Upload : tout utilisateur connecté - * - Liste : chaque utilisateur voit uniquement ses propres médias ; - * l'administrateur et l'éditeur voient tous les médias - * - Suppression : propriétaire du média, éditeur ou administrateur - */ final class MediaController { - /** - * @param Twig $view Moteur de templates Twig - * @param MediaServiceInterface $mediaService Service de gestion des médias - * @param FlashServiceInterface $flash Service de messages flash - * @param SessionManagerInterface $sessionManager Gestionnaire de session - */ + private const PER_PAGE = 12; + public function __construct( private readonly Twig $view, private readonly MediaServiceInterface $mediaService, @@ -45,45 +25,30 @@ final class MediaController ) { } - /** - * Affiche la page de gestion des médias. - * - * Un éditeur ou un administrateur voit tous les médias. - * Un utilisateur avec le rôle 'user' voit uniquement ses propres médias. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response La page HTML de gestion des médias - */ public function index(Request $req, Response $res): Response { $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); $userId = $this->sessionManager->getUserId(); + $page = PaginationPresenter::resolvePage($req->getQueryParams()); - $media = $isAdmin - ? $this->mediaService->findAll() - : $this->mediaService->findByUserId((int) $userId); + $paginated = $isAdmin + ? $this->mediaService->findPaginated($page, self::PER_PAGE) + : $this->mediaService->findByUserIdPaginated((int) $userId, $page, self::PER_PAGE); + + $usageByMediaId = []; + foreach ($paginated->getItems() as $item) { + $usageByMediaId[$item->getId()] = $this->mediaService->getUsageSummary($item, 5); + } return $this->view->render($res, 'admin/media/index.twig', [ - 'media' => $media, - 'error' => $this->flash->get('media_error'), - 'success' => $this->flash->get('media_success'), + 'media' => $paginated->getItems(), + 'mediaUsage' => $usageByMediaId, + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'error' => $this->flash->get('media_error'), + 'success' => $this->flash->get('media_success'), ]); } - /** - * Traite l'upload d'une image envoyée par le plugin Trumbowyg Upload. - * - * Vérifie la présence et l'absence d'erreur PSR-7 avant de déléguer - * à MediaService. Les erreurs métier (taille, MIME, stockage) sont - * converties en réponses JSON avec le code HTTP approprié. - * - * @param Request $req La requête HTTP multipart contenant le champ "image" - * @param Response $res La réponse HTTP - * - * @return Response JSON {"success": true, "file": "/media/..."} ou {"error": "..."} - */ public function upload(Request $req, Response $res): Response { $files = $req->getUploadedFiles(); @@ -107,16 +72,7 @@ final class MediaController } /** - * Supprime un média (fichier sur disque + entrée en base). - * - * Vérifie que l'utilisateur connecté est le propriétaire du média - * ou un administrateur / éditeur. Redirige avec un message flash dans les deux cas. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * @param array $args Paramètres de route (id) - * - * @return Response Redirection vers /admin/media + * @param array $args */ public function delete(Request $req, Response $res, array $args): Response { @@ -125,7 +81,6 @@ final class MediaController if ($media === null) { $this->flash->set('media_error', 'Fichier introuvable'); - return $res->withHeader('Location', '/admin/media')->withStatus(302); } @@ -134,6 +89,25 @@ final class MediaController if (!$isAdmin && $media->getUserId() !== $userId) { $this->flash->set('media_error', "Vous n'êtes pas autorisé à supprimer ce fichier"); + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + /** @var array $usage */ + $usage = $this->mediaService->getUsageSummary($media, 3); + $usageCount = isset($usage['count']) && is_int($usage['count']) ? $usage['count'] : 0; + /** @var array $usagePosts */ + $usagePosts = isset($usage['posts']) && is_array($usage['posts']) ? $usage['posts'] : []; + + if ($usageCount > 0) { + $titles = array_map( + static fn ($post) => '« ' . $post->getTitle() . ' »', + $usagePosts + ); + $details = $titles === [] ? '' : ' Utilisé dans : ' . implode(', ', $titles) . '.'; + $this->flash->set( + 'media_error', + 'Ce média est encore référencé dans ' . $usageCount . ' article(s) et ne peut pas être supprimé.' . $details + ); return $res->withHeader('Location', '/admin/media')->withStatus(302); } @@ -144,14 +118,6 @@ final class MediaController return $res->withHeader('Location', '/admin/media')->withStatus(302); } - /** - * Retourne une réponse JSON de succès avec l'URL du fichier uploadé. - * - * @param Response $res La réponse HTTP - * @param string $fileUrl L'URL publique du fichier - * - * @return Response La réponse JSON {"success": true, "file": "..."} - */ private function jsonSuccess(Response $res, string $fileUrl): Response { $res->getBody()->write(json_encode([ @@ -162,15 +128,6 @@ final class MediaController return $res->withHeader('Content-Type', 'application/json')->withStatus(200); } - /** - * Retourne une réponse JSON d'erreur. - * - * @param Response $res La réponse HTTP - * @param string $message Le message d'erreur - * @param int $status Le code HTTP de l'erreur - * - * @return Response La réponse JSON {"error": "..."} - */ private function jsonError(Response $res, string $message, int $status): Response { $res->getBody()->write(json_encode(['error' => $message], JSON_THROW_ON_ERROR)); diff --git a/src/Media/MediaRepository.php b/src/Media/MediaRepository.php index a010715..8f80c05 100644 --- a/src/Media/MediaRepository.php +++ b/src/Media/MediaRepository.php @@ -5,109 +5,104 @@ namespace App\Media; use PDO; -/** - * Dépôt pour la persistance des médias uploadés. - * - * Responsabilité unique : exécuter les requêtes SQL liées à la table `media` - * et retourner des instances de Media hydratées. - */ final class MediaRepository implements MediaRepositoryInterface { - /** - * Fragment SELECT commun à toutes les requêtes de lecture. - */ private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media'; - /** - * @param PDO $db Instance de connexion à la base de données - */ public function __construct(private readonly PDO $db) { } - /** - * Retourne tous les médias triés du plus récent au plus ancien. - * - * @return Media[] La liste complète des médias - */ public function findAll(): array { $stmt = $this->db->query(self::SELECT . ' ORDER BY id DESC'); if ($stmt === false) { throw new \RuntimeException('La requête SELECT sur media a échoué.'); } - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - return array_map(fn ($row) => Media::fromArray($row), $rows); + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function findPage(int $limit, int $offset): array + { + $stmt = $this->db->prepare(self::SELECT . ' ORDER BY id DESC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countAll(): int + { + $stmt = $this->db->query('SELECT COUNT(*) FROM media'); + if ($stmt === false) { + throw new \RuntimeException('La requête COUNT sur media a échoué.'); + } + + return (int) ($stmt->fetchColumn() ?: 0); } - /** - * Retourne tous les médias appartenant à un utilisateur donné, - * triés du plus récent au plus ancien. - * - * @param int $userId Identifiant de l'utilisateur - * - * @return Media[] La liste des médias de cet utilisateur - */ public function findByUserId(int $userId): array { $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC'); $stmt->execute([':user_id' => $userId]); - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - - return array_map(fn ($row) => Media::fromArray($row), $rows); + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function findByUserPage(int $userId, int $limit, int $offset): array + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countByUserId(int $userId): int + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM media WHERE user_id = :user_id'); + $stmt->execute([':user_id' => $userId]); + + return (int) $stmt->fetchColumn(); } - /** - * Trouve un média par son identifiant. - * - * @param int $id Identifiant du média - * - * @return Media|null Le média trouvé, ou null s'il n'existe pas - */ public function findById(int $id): ?Media { $stmt = $this->db->prepare(self::SELECT . ' WHERE id = :id'); $stmt->execute([':id' => $id]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? Media::fromArray($row) : null; } - /** - * Trouve un média par le hash SHA-256 de son contenu. - * - * Utilisé pour la détection des doublons à l'upload. - * - * @param string $hash Hash SHA-256 du contenu binaire du fichier - * - * @return Media|null Le média existant, ou null si aucun doublon - */ public function findByHash(string $hash): ?Media { - $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash'); + $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; } - /** - * Persiste un nouveau média en base de données. - * - * @param Media $media Le média à créer - * - * @return int L'identifiant généré par la base de données - */ + 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->execute([':hash' => $hash, ':user_id' => $userId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Media::fromArray($row) : null; + } + public function create(Media $media): int { - $stmt = $this->db->prepare(' - INSERT INTO media (filename, url, hash, user_id, created_at) - VALUES (:filename, :url, :hash, :user_id, :created_at) - '); + $stmt = $this->db->prepare( + 'INSERT INTO media (filename, url, hash, user_id, created_at) + VALUES (:filename, :url, :hash, :user_id, :created_at)' + ); $stmt->execute([ ':filename' => $media->getFilename(), @@ -120,15 +115,6 @@ final class MediaRepository implements MediaRepositoryInterface return (int) $this->db->lastInsertId(); } - /** - * Supprime un média de la base de données. - * - * La suppression du fichier physique sur disque est à la charge de l'appelant. - * - * @param int $id Identifiant du média à supprimer - * - * @return int Nombre de lignes supprimées (0 si le média n'existe plus) - */ public function delete(int $id): int { $stmt = $this->db->prepare('DELETE FROM media WHERE id = :id'); diff --git a/src/Media/MediaRepositoryInterface.php b/src/Media/MediaRepositoryInterface.php index 15725b1..25ef82e 100644 --- a/src/Media/MediaRepositoryInterface.php +++ b/src/Media/MediaRepositoryInterface.php @@ -3,63 +3,31 @@ declare(strict_types=1); namespace App\Media; -/** - * Contrat de persistance des médias uploadés. - * - * Découple les contrôleurs de l'implémentation concrète PDO/SQLite, - * facilitant les mocks dans les tests unitaires. - */ interface MediaRepositoryInterface { - /** - * Retourne tous les médias triés du plus récent au plus ancien. - * - * @return Media[] - */ + /** @return Media[] */ public function findAll(): array; - /** - * Retourne tous les médias d'un utilisateur donné. - * - * @param int $userId Identifiant de l'utilisateur - * - * @return Media[] - */ + /** @return Media[] */ + public function findPage(int $limit, int $offset): array; + + public function countAll(): int; + + /** @return Media[] */ public function findByUserId(int $userId): array; - /** - * Trouve un média par son identifiant. - * - * @param int $id Identifiant du média - * - * @return Media|null Le média trouvé, ou null s'il n'existe pas - */ + /** @return Media[] */ + public function findByUserPage(int $userId, int $limit, int $offset): array; + + public function countByUserId(int $userId): int; + public function findById(int $id): ?Media; - /** - * Trouve un média par le hash SHA-256 de son contenu (déduplication). - * - * @param string $hash Hash SHA-256 du contenu binaire du fichier - * - * @return Media|null Le média existant, ou null si aucun doublon - */ public function findByHash(string $hash): ?Media; - /** - * Persiste un nouveau média en base de données. - * - * @param Media $media Le média à créer - * - * @return int L'identifiant généré par la base de données - */ + public function findByHashForUser(string $hash, int $userId): ?Media; + public function create(Media $media): int; - /** - * Supprime un média de la base de données. - * - * @param int $id Identifiant du média à supprimer - * - * @return int Nombre de lignes supprimées - */ public function delete(int $id): int; } diff --git a/src/Media/MediaService.php b/src/Media/MediaService.php index a3c3054..1648540 100644 --- a/src/Media/MediaService.php +++ b/src/Media/MediaService.php @@ -6,38 +6,32 @@ namespace App\Media; use App\Media\Exception\FileTooLargeException; use App\Media\Exception\InvalidMimeTypeException; use App\Media\Exception\StorageException; +use App\Post\PostRepositoryInterface; +use App\Shared\Pagination\PaginatedResult; use PDOException; use Psr\Http\Message\UploadedFileInterface; final class MediaService implements MediaServiceInterface { - private const ALLOWED_MIME_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - ]; - + private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; private const WEBP_CONVERTIBLE = ['image/jpeg', 'image/png']; - private const MIME_EXTENSIONS = [ 'image/jpeg' => 'webp', 'image/png' => 'webp', 'image/gif' => 'gif', 'image/webp' => 'webp', ]; - private const MIME_EXTENSIONS_FALLBACK = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', ]; - private const MAX_PIXELS = 40000000; public function __construct( private readonly MediaRepositoryInterface $mediaRepository, + private readonly PostRepositoryInterface $postRepository, private readonly string $uploadDir, private readonly string $uploadUrl, private readonly int $maxSize, @@ -49,11 +43,45 @@ final class MediaService implements MediaServiceInterface return $this->mediaRepository->findAll(); } + /** + * @return PaginatedResult + */ + public function findPaginated(int $page, int $perPage): PaginatedResult + { + $page = max(1, $page); + $total = $this->mediaRepository->countAll(); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->mediaRepository->findPage($perPage, $offset), + $total, + $page, + $perPage, + ); + } + public function findByUserId(int $userId): array { return $this->mediaRepository->findByUserId($userId); } + /** + * @return PaginatedResult + */ + public function findByUserIdPaginated(int $userId, int $page, int $perPage): PaginatedResult + { + $page = max(1, $page); + $total = $this->mediaRepository->countByUserId($userId); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->mediaRepository->findByUserPage($userId, $perPage, $offset), + $total, + $page, + $perPage, + ); + } + public function findById(int $id): ?Media { return $this->mediaRepository->findById($id); @@ -106,7 +134,7 @@ final class MediaService implements MediaServiceInterface } $hash = $rawHash; - $existing = $this->mediaRepository->findByHash($hash); + $existing = $this->mediaRepository->findByHashForUser($hash, $userId); if ($existing !== null) { if ($converted) { @@ -119,9 +147,7 @@ final class MediaService implements MediaServiceInterface throw new StorageException("Impossible de créer le répertoire d'upload"); } - $extension = $converted - ? self::MIME_EXTENSIONS[$mime] - : self::MIME_EXTENSIONS_FALLBACK[$mime]; + $extension = $converted ? self::MIME_EXTENSIONS[$mime] : self::MIME_EXTENSIONS_FALLBACK[$mime]; $filename = uniqid('', true) . '_' . bin2hex(random_bytes(4)) . '.' . $extension; $destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; @@ -141,19 +167,27 @@ final class MediaService implements MediaServiceInterface try { $this->mediaRepository->create($media); } catch (PDOException $e) { - $duplicate = $this->mediaRepository->findByHash($hash); + @unlink($destPath); + + $duplicate = $this->mediaRepository->findByHashForUser($hash, $userId); if ($duplicate !== null) { - @unlink($destPath); return $duplicate->getUrl(); } - @unlink($destPath); throw $e; } return $url; } + public function getUsageSummary(Media $media, int $sampleLimit = 5): array + { + return [ + 'count' => $this->postRepository->countByEmbeddedMediaUrl($media->getUrl()), + 'posts' => $this->postRepository->findByEmbeddedMediaUrl($media->getUrl(), $sampleLimit), + ]; + } + public function delete(Media $media): void { $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $media->getFilename(); diff --git a/src/Media/MediaServiceInterface.php b/src/Media/MediaServiceInterface.php index 7ed5d57..b0b7b14 100644 --- a/src/Media/MediaServiceInterface.php +++ b/src/Media/MediaServiceInterface.php @@ -3,60 +3,33 @@ declare(strict_types=1); namespace App\Media; +use App\Shared\Pagination\PaginatedResult; use Psr\Http\Message\UploadedFileInterface; -/** - * Contrat du service de gestion des médias. - * - * Permet de mocker le service dans les tests unitaires sans dépendre - * de la classe concrète finale MediaService. - */ interface MediaServiceInterface { - /** - * Retourne tous les médias triés du plus récent au plus ancien. - * - * @return Media[] - */ + /** @return Media[] */ public function findAll(): array; /** - * Retourne tous les médias appartenant à un utilisateur donné. - * - * @param int $userId Identifiant de l'utilisateur - * - * @return Media[] + * @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult; + + /** @return Media[] */ public function findByUserId(int $userId): array; /** - * Trouve un média par son identifiant. - * - * @param int $id Identifiant du média - * - * @return Media|null Le média trouvé, ou null s'il n'existe pas + * @return PaginatedResult */ + public function findByUserIdPaginated(int $userId, int $page, int $perPage): PaginatedResult; + public function findById(int $id): ?Media; - /** - * Valide, convertit, déduplique et stocke un fichier uploadé. - * - * @param UploadedFileInterface $uploadedFile Le fichier PSR-7 reçu - * @param int $userId Identifiant de l'auteur - * - * @return string L'URL publique du fichier stocké - * - * @throws \App\Media\Exception\FileTooLargeException Si la taille dépasse le maximum autorisé - * @throws \App\Media\Exception\InvalidMimeTypeException Si le type MIME n'est pas autorisé - * @throws \App\Media\Exception\StorageException Si une opération disque échoue - */ public function store(UploadedFileInterface $uploadedFile, int $userId): string; - /** - * Supprime un média : fichier physique sur disque et entrée en base. - * - * @param Media $media Le média à supprimer - * @return void - */ + /** @return array{count:int, posts:array} */ + public function getUsageSummary(Media $media, int $sampleLimit = 5): array; + public function delete(Media $media): void; } diff --git a/src/Post/PostController.php b/src/Post/PostController.php index 56a768e..0ecafb6 100644 --- a/src/Post/PostController.php +++ b/src/Post/PostController.php @@ -7,32 +7,17 @@ use App\Category\CategoryServiceInterface; use App\Shared\Exception\NotFoundException; use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; +use App\Shared\Pagination\PaginationPresenter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Exception\HttpNotFoundException; use Slim\Views\Twig; -/** - * Contrôleur pour les articles. - * - * Gère les actions HTTP liées aux articles : affichage public et administration - * (liste, formulaire, création, modification, suppression). - * Délègue toute la logique métier à PostService et utilise FlashService - * pour transmettre les messages d'erreur entre redirections. - * L'identifiant de l'auteur est lu depuis SessionManager lors de la création. - * Les droits de modification et suppression sont vérifiés via canEditPost(). - * CategoryService est injecté pour résoudre les slugs de catégorie - * en identifiants et fournir la liste des catégories aux vues. - */ final class PostController { - /** - * @param Twig $view Moteur de templates Twig - * @param PostServiceInterface $postService Service métier des articles - * @param CategoryServiceInterface $categoryService Service de gestion des catégories - * @param FlashServiceInterface $flash Service de messages flash - * @param SessionManagerInterface $sessionManager Gestionnaire de session - */ + private const PUBLIC_PER_PAGE = 6; + private const ADMIN_PER_PAGE = 12; + public function __construct( private readonly Twig $view, private readonly PostServiceInterface $postService, @@ -42,24 +27,10 @@ final class PostController ) { } - /** - * Affiche la page d'accueil avec la liste des articles. - * - * Accepte deux paramètres de requête cumulables : - * - `q` (string) : recherche plein texte FTS5 sur titre, contenu et auteur - * - `categorie` (string) : filtre par slug de catégorie - * - * Si `q` est fourni, les résultats sont triés par pertinence BM25. - * Sans `q`, les articles sont triés du plus récent au plus ancien. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response La vue de la page d'accueil - */ public function index(Request $req, Response $res): Response { $params = $req->getQueryParams(); + $page = PaginationPresenter::resolvePage($params); $searchQuery = trim((string) ($params['q'] ?? '')); $categorySlug = (string) ($params['categorie'] ?? ''); $activeCategory = null; @@ -70,12 +41,14 @@ final class PostController $categoryId = $activeCategory?->getId(); } - $posts = $searchQuery !== '' - ? $this->postService->searchPosts($searchQuery, $categoryId) - : $this->postService->getAllPosts($categoryId); + $paginated = $searchQuery !== '' + ? $this->postService->searchPostsPaginated($searchQuery, $page, self::PUBLIC_PER_PAGE, $categoryId) + : $this->postService->getAllPostsPaginated($page, self::PUBLIC_PER_PAGE, $categoryId); return $this->view->render($res, 'pages/home.twig', [ - 'posts' => $posts, + 'posts' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'totalPosts' => $paginated->getTotal(), 'categories' => $this->categoryService->findAll(), 'activeCategory' => $activeCategory, 'searchQuery' => $searchQuery, @@ -83,19 +56,7 @@ final class PostController } /** - * Affiche le détail d'un article par son slug. - * - * Le contenu HTML est déjà sanitisé lors de la création/modification - * (via HtmlSanitizerInterface dans PostService) : aucun nettoyage supplémentaire - * n'est nécessaire à la lecture. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * @param array $args Les paramètres de route (slug) - * - * @return Response La vue de détail de l'article - * - * @throws HttpNotFoundException Si aucun article ne correspond au slug + * @param array $args */ public function show(Request $req, Response $res, array $args): Response { @@ -108,30 +69,12 @@ final class PostController return $this->view->render($res, 'pages/post/detail.twig', ['post' => $post]); } - /** - * Affiche la liste des articles dans l'interface d'administration. - * - * Un administrateur ou un éditeur voit tous les articles. - * Un utilisateur normal voit uniquement ses propres articles. - * - * Accepte deux paramètres de requête cumulables : - * - `q` (string) : recherche plein texte FTS5 sur titre, contenu et auteur - * - `categorie` (string) : filtre par slug de catégorie - * - * Si `q` est fourni, les résultats sont triés par pertinence BM25. - * Sans `q`, les articles sont triés du plus récent au plus ancien. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response La vue d'administration des posts - */ public function admin(Request $req, Response $res): Response { $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); $userId = $this->sessionManager->getUserId(); - $params = $req->getQueryParams(); + $page = PaginationPresenter::resolvePage($params); $searchQuery = trim((string) ($params['q'] ?? '')); $categorySlug = (string) ($params['categorie'] ?? ''); $activeCategory = null; @@ -143,16 +86,24 @@ final class PostController } if ($searchQuery !== '') { - $authorId = $isAdmin ? null : (int) $userId; - $posts = $this->postService->searchPosts($searchQuery, $categoryId, $authorId); + $authorId = $isAdmin ? null : (int) $userId; + $paginated = $this->postService->searchPostsPaginated( + $searchQuery, + $page, + self::ADMIN_PER_PAGE, + $categoryId, + $authorId, + ); } else { - $posts = $isAdmin - ? $this->postService->getAllPosts($categoryId) - : $this->postService->getPostsByUserId((int) $userId, $categoryId); + $paginated = $isAdmin + ? $this->postService->getAllPostsPaginated($page, self::ADMIN_PER_PAGE, $categoryId) + : $this->postService->getPostsByUserIdPaginated((int) $userId, $page, self::ADMIN_PER_PAGE, $categoryId); } return $this->view->render($res, 'admin/posts/index.twig', [ - 'posts' => $posts, + 'posts' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'totalPosts' => $paginated->getTotal(), 'categories' => $this->categoryService->findAll(), 'activeCategory' => $activeCategory, 'searchQuery' => $searchQuery, @@ -162,18 +113,7 @@ final class PostController } /** - * Affiche le formulaire de création (id=0) ou d'édition d'un article. - * - * L'accès en édition est refusé si l'utilisateur n'est pas l'auteur - * de l'article et n'a pas le rôle admin. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * @param array $args Les paramètres de route (id) - * - * @return Response Le formulaire ou une redirection - * - * @throws HttpNotFoundException Si l'article demandé n'existe pas + * @param array $args */ public function form(Request $req, Response $res, array $args): Response { @@ -187,7 +127,6 @@ final class PostController throw new HttpNotFoundException($req); } - // Vérification des droits avant affichage du formulaire if (!$this->canEditPost($post)) { $this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur"); @@ -203,24 +142,9 @@ final class PostController ]); } - /** - * Traite la soumission du formulaire de création d'article. - * - * L'auteur est l'utilisateur connecté, lu depuis la session. - * Le slug est généré automatiquement depuis le titre par PostService — - * la valeur éventuellement saisie dans le formulaire est ignorée à la création - * (elle n'est prise en compte qu'à la modification via update()). - * En cas d'erreur de validation, redirige vers le formulaire avec un message flash. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response Une redirection vers /admin/posts ou /admin/posts/edit/0 - */ public function create(Request $req, Response $res): Response { - ['title' => $title, 'content' => $content, 'category_id' => $categoryId] = - $this->extractPostData($req); + ['title' => $title, 'content' => $content, 'category_id' => $categoryId] = $this->extractPostData($req); try { $this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId); @@ -239,27 +163,13 @@ final class PostController } /** - * Traite la soumission du formulaire de modification d'article. - * - * Vérifie les droits avant modification : seul l'auteur ou un admin peut modifier. - * Un second 404 est possible si l'article est supprimé entre la vérification - * des droits et l'UPDATE (race condition). - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * @param array $args Les paramètres de route (id) - * - * @return Response Une redirection vers /admin/posts ou vers le formulaire - * - * @throws HttpNotFoundException Si l'article n'existe pas ou a disparu (race condition) + * @param array $args */ public function update(Request $req, Response $res, array $args): Response { $id = (int) $args['id']; - ['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] = - $this->extractPostData($req); + ['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] = $this->extractPostData($req); - // Récupération de l'article pour vérification des droits avant modification try { $post = $this->postService->getPostById($id); } catch (NotFoundException) { @@ -276,7 +186,6 @@ final class PostController $this->postService->updatePost($id, $title, $content, $slug, $categoryId); $this->flash->set('post_success', 'L\'article a été modifié avec succès'); } catch (NotFoundException) { - // L'article a disparu entre la vérification des droits et l'UPDATE (race condition) throw new HttpNotFoundException($req); } catch (\InvalidArgumentException $e) { $this->flash->set('post_error', $e->getMessage()); @@ -292,23 +201,10 @@ final class PostController } /** - * Supprime un article. - * - * Vérifie les droits avant suppression : seul l'auteur ou un admin peut supprimer. - * Un second 404 est possible si l'article est supprimé entre la vérification - * des droits et le DELETE (race condition — cohérent avec update()). - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * @param array $args Les paramètres de route (id) - * - * @return Response Une redirection vers /admin/posts - * - * @throws HttpNotFoundException Si l'article n'existe pas ou a disparu (race condition) + * @param array $args */ public function delete(Request $req, Response $res, array $args): Response { - // Récupération de l'article pour vérification des droits avant suppression try { $post = $this->postService->getPostById((int) $args['id']); } catch (NotFoundException) { @@ -324,7 +220,6 @@ final class PostController try { $this->postService->deletePost($post->getId()); } catch (NotFoundException) { - // L'article a disparu entre la vérification des droits et le DELETE (race condition) throw new HttpNotFoundException($req); } @@ -333,41 +228,22 @@ final class PostController return $res->withHeader('Location', '/admin/posts')->withStatus(302); } - /** - * Vérifie si l'utilisateur connecté est autorisé à modifier ou supprimer un article. - * - * L'accès est accordé si l'utilisateur est l'auteur de l'article - * ou s'il a le rôle administrateur. - * - * @param Post $post L'article concerné - * - * @return bool True si l'action est autorisée - */ private function canEditPost(Post $post): bool { - // Un administrateur ou un éditeur a tous les droits sur tous les articles if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) { return true; } - // Un utilisateur standard ne peut agir que sur ses propres articles return $post->getAuthorId() === $this->sessionManager->getUserId(); } /** - * Extrait et normalise les données d'article depuis le corps de la requête. - * - * @param Request $req La requête HTTP - * - * @return array{title: string, content: string, slug: string, category_id: int|null} Les données nettoyées + * @return array{title: string, content: string, slug: string, category_id: int|null} */ private function extractPostData(Request $req): array { - /** @var array $data */ $data = (array) $req->getParsedBody(); - $categoryId = ($data['category_id'] ?? '') !== '' - ? (int) $data['category_id'] - : null; + $categoryId = ($data['category_id'] ?? '') !== '' ? (int) $data['category_id'] : null; return [ 'title' => trim((string) ($data['title'] ?? '')), diff --git a/src/Post/PostRepository.php b/src/Post/PostRepository.php index 404de0c..ae10570 100644 --- a/src/Post/PostRepository.php +++ b/src/Post/PostRepository.php @@ -5,20 +5,8 @@ namespace App\Post; use PDO; -/** - * Dépôt pour la persistance des articles. - * - * Responsabilité unique : exécuter les requêtes SQL liées à la table `posts` - * et retourner des instances de Post hydratées. - * Chaque requête de lecture effectue un LEFT JOIN sur `users` pour charger - * le nom d'auteur, et un LEFT JOIN sur `categories` pour charger le nom et - * le slug de catégorie — sans requête supplémentaire. - */ final class PostRepository implements PostRepositoryInterface { - /** - * Fragment SELECT commun à toutes les requêtes de lecture (avec JOINs). - */ private const SELECT = ' SELECT posts.id, posts.title, posts.content, posts.slug, posts.author_id, posts.category_id, posts.created_at, posts.updated_at, @@ -30,130 +18,150 @@ final class PostRepository implements PostRepositoryInterface LEFT JOIN categories ON categories.id = posts.category_id '; - /** - * @param PDO $db Instance de connexion à la base de données - */ public function __construct(private readonly PDO $db) { } - /** - * Retourne tous les articles triés du plus récent au plus ancien. - * - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * - * @return Post[] La liste des articles - */ public function findAll(?int $categoryId = null): array { - if ($categoryId !== null) { - $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC'); - $stmt->execute([':category_id' => $categoryId]); - } else { + if ($categoryId === null) { $stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC'); if ($stmt === false) { throw new \RuntimeException('La requête SELECT sur posts a échoué.'); } + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); } - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC'); + $stmt->execute([':category_id' => $categoryId]); - return array_map(fn ($row) => Post::fromArray($row), $rows); + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function findPage(int $limit, int $offset, ?int $categoryId = null): array + { + $sql = self::SELECT; + $params = []; + + if ($categoryId !== null) { + $sql .= ' WHERE posts.category_id = :category_id'; + $params[':category_id'] = $categoryId; + } + + $sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset'; + $stmt = $this->db->prepare($sql); + $this->bindParams($stmt, $params); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countAll(?int $categoryId = null): int + { + if ($categoryId === null) { + $stmt = $this->db->query('SELECT COUNT(*) FROM posts'); + if ($stmt === false) { + throw new \RuntimeException('La requête COUNT sur posts a échoué.'); + } + + return (int) ($stmt->fetchColumn() ?: 0); + } + + $stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id'); + $stmt->execute([':category_id' => $categoryId]); + + return (int) $stmt->fetchColumn(); } - /** - * Retourne les N articles les plus récents, tous auteurs confondus. - * - * @param int $limit Nombre maximum d'articles à retourner - * - * @return Post[] Les articles les plus récents - */ public function findRecent(int $limit): array { $stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.id DESC LIMIT :limit'); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - - return array_map(fn ($row) => Post::fromArray($row), $rows); + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); } - /** - * Retourne tous les articles d'un utilisateur donné, triés du plus récent au plus ancien. - * - * @param int $userId Identifiant de l'auteur - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * - * @return Post[] La liste des articles de cet utilisateur - */ public function findByUserId(int $userId, ?int $categoryId = null): array { + $sql = self::SELECT . ' WHERE posts.author_id = :author_id'; + $params = [':author_id' => $userId]; + if ($categoryId !== null) { - $stmt = $this->db->prepare( - self::SELECT . ' WHERE posts.author_id = :author_id AND posts.category_id = :category_id ORDER BY posts.id DESC' - ); - $stmt->execute([':author_id' => $userId, ':category_id' => $categoryId]); - } else { - $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.author_id = :author_id ORDER BY posts.id DESC'); - $stmt->execute([':author_id' => $userId]); + $sql .= ' AND posts.category_id = :category_id'; + $params[':category_id'] = $categoryId; } - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + $sql .= ' ORDER BY posts.id DESC'; + $stmt = $this->db->prepare($sql); + $stmt->execute($params); - return array_map(fn ($row) => Post::fromArray($row), $rows); + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array + { + $sql = self::SELECT . ' WHERE posts.author_id = :author_id'; + $params = [':author_id' => $userId]; + + if ($categoryId !== null) { + $sql .= ' AND posts.category_id = :category_id'; + $params[':category_id'] = $categoryId; + } + + $sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset'; + $stmt = $this->db->prepare($sql); + $this->bindParams($stmt, $params); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countByUserId(int $userId, ?int $categoryId = null): int + { + $sql = 'SELECT COUNT(*) FROM posts WHERE author_id = :author_id'; + $params = [':author_id' => $userId]; + + if ($categoryId !== null) { + $sql .= ' AND category_id = :category_id'; + $params[':category_id'] = $categoryId; + } + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + + return (int) $stmt->fetchColumn(); } - /** - * Trouve un article par son slug URL. - * - * @param string $slug Le slug URL de l'article - * - * @return Post|null L'article trouvé, ou null s'il n'existe pas - */ public function findBySlug(string $slug): ?Post { $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug'); $stmt->execute([':slug' => $slug]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? Post::fromArray($row) : null; } - /** - * Trouve un article par son identifiant. - * - * @param int $id Identifiant de l'article - * - * @return Post|null L'article trouvé, ou null s'il n'existe pas - */ public function findById(int $id): ?Post { $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id'); $stmt->execute([':id' => $id]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? Post::fromArray($row) : null; } - /** - * Persiste un nouvel article en base de données. - * - * @param Post $post L'article à créer - * @param string $slug Le slug unique généré pour cet article - * @param int $authorId Identifiant de l'auteur - * @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie) - * - * @return int L'identifiant généré par la base de données - */ public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int { - $stmt = $this->db->prepare(' - INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at) - VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at) - '); + $stmt = $this->db->prepare( + 'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at) + VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)' + ); $stmt->execute([ ':title' => $post->getTitle(), @@ -168,27 +176,14 @@ final class PostRepository implements PostRepositoryInterface return (int) $this->db->lastInsertId(); } - /** - * Met à jour un article existant en base de données. - * - * Retourne le nombre de lignes affectées. Une valeur de 0 indique que - * l'article n'existe plus au moment de l'écriture (suppression concurrente). - * - * @param int $id Identifiant de l'article à modifier - * @param Post $post L'article avec les nouvelles données - * @param string $slug Le nouveau slug unique - * @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie) - * - * @return int Nombre de lignes affectées (0 si l'article n'existe plus) - */ public function update(int $id, Post $post, string $slug, ?int $categoryId): int { - $stmt = $this->db->prepare(' - UPDATE posts - SET title = :title, content = :content, slug = :slug, - category_id = :category_id, updated_at = :updated_at - WHERE id = :id - '); + $stmt = $this->db->prepare( + 'UPDATE posts + SET title = :title, content = :content, slug = :slug, + category_id = :category_id, updated_at = :updated_at + WHERE id = :id' + ); $stmt->execute([ ':title' => $post->getTitle(), @@ -202,13 +197,6 @@ final class PostRepository implements PostRepositoryInterface return $stmt->rowCount(); } - /** - * Supprime un article de la base de données. - * - * @param int $id Identifiant de l'article à supprimer - * - * @return int Nombre de lignes supprimées (0 si l'article n'existe plus) - */ public function delete(int $id): int { $stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id'); @@ -217,23 +205,6 @@ final class PostRepository implements PostRepositoryInterface return $stmt->rowCount(); } - /** - * Recherche des articles en plein texte via l'index FTS5. - * - * La requête est tokenisée mot par mot : chaque terme est traité comme un - * préfixe (ex: "slim" correspond à "Slim", "Slimframework"…). Les termes - * sont combinés en AND implicite — tous doivent être présents dans le document. - * Les caractères spéciaux FTS5 sont échappés par guillemets doubles. - * - * Les résultats sont triés par pertinence BM25 (meilleur en premier). - * Filtrages optionnels disponibles : par catégorie et/ou par auteur. - * - * @param string $query La saisie utilisateur brute - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * @param int|null $authorId Filtre optionnel par identifiant d'auteur (rôle user) - * - * @return Post[] Les articles correspondant à la recherche, triés par pertinence - */ public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array { $ftsQuery = $this->buildFtsQuery($query); @@ -242,19 +213,50 @@ final class PostRepository implements PostRepositoryInterface return []; } + [$sql, $params] = $this->buildSearchSql($ftsQuery, $categoryId, $authorId); + $sql .= ' ORDER BY rank'; + + $stmt = $this->db->prepare($sql); + $this->bindParams($stmt, $params); + $stmt->execute(); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array + { + $ftsQuery = $this->buildFtsQuery($query); + + if ($ftsQuery === '') { + return []; + } + + [$sql, $params] = $this->buildSearchSql($ftsQuery, $categoryId, $authorId); + $sql .= ' ORDER BY rank LIMIT :limit OFFSET :offset'; + + $stmt = $this->db->prepare($sql); + $this->bindParams($stmt, $params); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int + { + $ftsQuery = $this->buildFtsQuery($query); + + if ($ftsQuery === '') { + return 0; + } + $sql = ' - SELECT p.id, p.title, p.content, p.slug, - p.author_id, p.category_id, p.created_at, p.updated_at, - u.username AS author_username, - c.name AS category_name, - c.slug AS category_slug + SELECT COUNT(*) FROM posts_fts f - JOIN posts p ON p.id = f.rowid - LEFT JOIN users u ON u.id = p.author_id - LEFT JOIN categories c ON c.id = p.category_id + JOIN posts p ON p.id = f.rowid WHERE posts_fts MATCH :query '; - $params = [':query' => $ftsQuery]; if ($categoryId !== null) { @@ -267,27 +269,80 @@ final class PostRepository implements PostRepositoryInterface $params[':author_id'] = $authorId; } - $sql .= ' ORDER BY rank'; - $stmt = $this->db->prepare($sql); - $stmt->execute($params); + $this->bindParams($stmt, $params); + $stmt->execute(); - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + return (int) $stmt->fetchColumn(); + } - return array_map(fn ($row) => Post::fromArray($row), $rows); + public function slugExists(string $slug, ?int $excludeId = null): bool + { + $stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug'); + $stmt->execute([':slug' => $slug]); + $existingId = $stmt->fetchColumn(); + + if ($existingId === false) { + return false; + } + + $existingId = (int) $existingId; + + return $excludeId !== null ? $existingId !== $excludeId : true; + } + + public function countByEmbeddedMediaUrl(string $url): int + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE instr(content, :url) > 0'); + $stmt->execute([':url' => $url]); + + return (int) $stmt->fetchColumn(); + } + + public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array + { + $stmt = $this->db->prepare( + self::SELECT . ' WHERE instr(posts.content, :url) > 0 ORDER BY posts.updated_at DESC LIMIT :limit' + ); + $stmt->bindValue(':url', $url, PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); } /** - * Construit une requête FTS5 sûre depuis la saisie utilisateur. - * - * Chaque mot est wrappé entre guillemets doubles (échappement interne - * des guillemets par doublement) et suivi d'un `*` pour la recherche - * par préfixe. Les mots sont joints par un espace (AND implicite FTS5). - * - * @param string $input La saisie brute de l'utilisateur - * - * @return string La requête FTS5 prête à l'emploi, ou '' si vide + * @return array{0:string,1:array} */ + private function buildSearchSql(string $ftsQuery, ?int $categoryId = null, ?int $authorId = null): array + { + $sql = ' + SELECT p.id, p.title, p.content, p.slug, + p.author_id, p.category_id, p.created_at, p.updated_at, + u.username AS author_username, + c.name AS category_name, + c.slug AS category_slug + FROM posts_fts f + JOIN posts p ON p.id = f.rowid + LEFT JOIN users u ON u.id = p.author_id + LEFT JOIN categories c ON c.id = p.category_id + WHERE posts_fts MATCH :query + '; + $params = [':query' => $ftsQuery]; + + if ($categoryId !== null) { + $sql .= ' AND p.category_id = :category_id'; + $params[':category_id'] = $categoryId; + } + + if ($authorId !== null) { + $sql .= ' AND p.author_id = :author_id'; + $params[':author_id'] = $authorId; + } + + return [$sql, $params]; + } + private function buildFtsQuery(string $input): string { $words = preg_split('/\s+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: []; @@ -305,30 +360,21 @@ final class PostRepository implements PostRepositoryInterface } /** - * Vérifie si un slug est déjà utilisé par un autre article. - * - * @param string $slug Le slug à vérifier - * @param int|null $excludeId Identifiant à exclure de la vérification (pour les mises à jour) - * - * @return bool True si le slug est déjà pris par un autre article + * @param array $params */ - public function slugExists(string $slug, ?int $excludeId = null): bool + private function bindParams(\PDOStatement $stmt, array $params): void { - $stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug'); - $stmt->execute([':slug' => $slug]); - - $existingId = $stmt->fetchColumn(); - - if ($existingId === false) { - return false; + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); } + } - $existingId = (int) $existingId; - - if ($excludeId !== null) { - return $existingId !== $excludeId; - } - - return true; + /** + * @param array> $rows + * @return Post[] + */ + private function hydratePosts(array $rows): array + { + return array_map(fn ($row) => Post::fromArray($row), $rows); } } diff --git a/src/Post/PostRepositoryInterface.php b/src/Post/PostRepositoryInterface.php index 14936b8..e6afc52 100644 --- a/src/Post/PostRepositoryInterface.php +++ b/src/Post/PostRepositoryInterface.php @@ -3,111 +3,49 @@ declare(strict_types=1); namespace App\Post; -/** - * Contrat de persistance des articles. - * - * Découple PostService de l'implémentation concrète PDO/SQLite, - * facilitant les mocks dans les tests unitaires. - */ interface PostRepositoryInterface { - /** - * Retourne tous les articles triés du plus récent au plus ancien. - * - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * - * @return Post[] - */ + /** @return Post[] */ public function findAll(?int $categoryId = null): array; - /** - * Retourne les N articles les plus récents (flux RSS). - * - * @param int $limit Nombre maximum d'articles à retourner - * - * @return Post[] - */ + /** @return Post[] */ + public function findPage(int $limit, int $offset, ?int $categoryId = null): array; + + public function countAll(?int $categoryId = null): int; + + /** @return Post[] */ public function findRecent(int $limit): array; - /** - * Retourne tous les articles d'un utilisateur donné. - * - * @param int $userId Identifiant de l'auteur - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * - * @return Post[] - */ + /** @return Post[] */ public function findByUserId(int $userId, ?int $categoryId = null): array; - /** - * Trouve un article par son slug URL. - * - * @param string $slug Le slug URL de l'article - * - * @return Post|null L'article trouvé, ou null s'il n'existe pas - */ + /** @return Post[] */ + public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array; + + public function countByUserId(int $userId, ?int $categoryId = null): int; + public function findBySlug(string $slug): ?Post; - /** - * Trouve un article par son identifiant. - * - * @param int $id Identifiant de l'article - * - * @return Post|null L'article trouvé, ou null s'il n'existe pas - */ public function findById(int $id): ?Post; - /** - * Persiste un nouvel article en base de données. - * - * @param Post $post L'article à créer - * @param string $slug Le slug unique généré pour cet article - * @param int $authorId Identifiant de l'auteur - * @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie) - * - * @return int L'identifiant généré par la base de données - */ public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int; - /** - * Met à jour un article existant. - * - * @param int $id Identifiant de l'article à modifier - * @param Post $post L'article avec les nouvelles données - * @param string $slug Le nouveau slug unique - * @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie) - * - * @return int Nombre de lignes affectées - */ public function update(int $id, Post $post, string $slug, ?int $categoryId): int; - /** - * Supprime un article de la base de données. - * - * @param int $id Identifiant de l'article à supprimer - * - * @return int Nombre de lignes supprimées - */ public function delete(int $id): int; - /** - * Recherche des articles en plein texte via FTS5. - * - * @param string $query La saisie utilisateur brute - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * @param int|null $authorId Filtre optionnel par identifiant d'auteur - * - * @return Post[] - */ + /** @return Post[] */ public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array; - /** - * Vérifie si un slug est déjà utilisé par un autre article. - * - * @param string $slug Le slug à vérifier - * @param int|null $excludeId Identifiant à exclure (mise à jour) - * - * @return bool True si le slug est déjà pris - */ + /** @return Post[] */ + public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array; + + public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int; + public function slugExists(string $slug, ?int $excludeId = null): bool; + + public function countByEmbeddedMediaUrl(string $url): int; + + /** @return Post[] */ + public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array; } diff --git a/src/Post/PostService.php b/src/Post/PostService.php index 2d82e5e..f8d4b5c 100644 --- a/src/Post/PostService.php +++ b/src/Post/PostService.php @@ -5,82 +5,66 @@ namespace App\Post; use App\Shared\Exception\NotFoundException; use App\Shared\Html\HtmlSanitizerInterface; +use App\Shared\Pagination\PaginatedResult; use App\Shared\Util\SlugHelper; -/** - * Service métier pour les articles. - * - * Centralise toute la logique qui ne relève ni du stockage (PostRepository) - * ni de la présentation (PostController / PostExtension) : - * - génération et unicité des slugs - * - sanitisation du contenu HTML à l'écriture - * - orchestration des opérations create / update / delete - * - * Flux de sanitisation : - * 1. L'utilisateur saisit du HTML via Trumbowyg - * 2. createPost() / updatePost() passent le contenu brut à HtmlSanitizerInterface - * 3. HtmlSanitizerInterface (implémentée par HtmlSanitizer) délègue à HTMLPurifier, configuré pour n'autoriser - * que les balises produites par Trumbowyg - * 4. Le contenu purifié est stocké en base — le filtre |raw dans Twig est sûr - */ final class PostService implements PostServiceInterface { - /** - * @param PostRepositoryInterface $postRepository Dépôt de persistance des articles - * @param HtmlSanitizerInterface $htmlSanitizer Service de sanitisation HTML - */ public function __construct( private readonly PostRepositoryInterface $postRepository, private readonly HtmlSanitizerInterface $htmlSanitizer, ) { } - /** - * Retourne tous les articles triés du plus récent au plus ancien. - * - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * - * @return Post[] - */ public function getAllPosts(?int $categoryId = null): array { return $this->postRepository->findAll($categoryId); } /** - * Retourne les N articles les plus récents pour le flux RSS. - * - * @param int $limit Nombre maximum d'articles à retourner (défaut : 20) - * - * @return Post[] + * @return PaginatedResult */ + public function getAllPostsPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult + { + $page = max(1, $page); + $total = $this->postRepository->countAll($categoryId); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->postRepository->findPage($perPage, $offset, $categoryId), + $total, + $page, + $perPage, + ); + } + public function getRecentPosts(int $limit = 20): array { return $this->postRepository->findRecent($limit); } - /** - * Retourne tous les articles d'un utilisateur donné. - * - * @param int $userId Identifiant de l'auteur - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * - * @return Post[] - */ public function getPostsByUserId(int $userId, ?int $categoryId = null): array { return $this->postRepository->findByUserId($userId, $categoryId); } /** - * Retourne un article par son slug URL. - * - * @param string $slug Le slug URL de l'article - * - * @return Post L'article avec contenu sûr - * - * @throws NotFoundException Si aucun article ne correspond au slug + * @return PaginatedResult */ + public function getPostsByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult + { + $page = max(1, $page); + $total = $this->postRepository->countByUserId($userId, $categoryId); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->postRepository->findByUserPage($userId, $perPage, $offset, $categoryId), + $total, + $page, + $perPage, + ); + } + public function getPostBySlug(string $slug): Post { $post = $this->postRepository->findBySlug($slug); @@ -92,15 +76,6 @@ final class PostService implements PostServiceInterface return $post; } - /** - * Retourne un article par son identifiant. - * - * @param int $id Identifiant de l'article - * - * @return Post L'article avec son contenu - * - * @throws NotFoundException Si aucun article ne correspond à cet identifiant - */ public function getPostById(int $id): Post { $post = $this->postRepository->findById($id); @@ -112,22 +87,6 @@ final class PostService implements PostServiceInterface return $post; } - /** - * Crée un nouvel article et retourne son identifiant. - * - * Un slug unique est généré à partir du titre. Si le slug existe déjà, - * un suffixe numérique est ajouté (ex: "mon-article-2"). - * Le contenu HTML est sanitisé avant stockage. - * - * @param string $title Titre de l'article - * @param string $content Contenu HTML brut (sera sanitisé) - * @param int $authorId Identifiant de l'auteur - * @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie) - * - * @return int L'identifiant de l'article créé - * - * @throws \InvalidArgumentException Si le titre ou le contenu sont invalides - */ public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int { $sanitizedContent = $this->htmlSanitizer->sanitize($content); @@ -137,29 +96,8 @@ final class PostService implements PostServiceInterface return $this->postRepository->create($post, $slug, $authorId, $categoryId); } - /** - * Met à jour un article existant. - * - * Le slug est préservé par défaut. Si $newSlugInput est fourni et différent - * du slug actuel, il est nettoyé puis rendu unique avant d'être appliqué. - * - * @param int $id Identifiant de l'article à modifier - * @param string $title Nouveau titre - * @param string $content Nouveau contenu HTML brut (sera sanitisé) - * @param string $newSlugInput Nouveau slug souhaité (vide = conserver l'actuel) - * @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie) - * - * @throws NotFoundException Si l'article n'existe plus - * @throws \InvalidArgumentException Si le titre ou le contenu sont invalides - * @return void - */ - public function updatePost( - int $id, - string $title, - string $content, - string $newSlugInput = '', - ?int $categoryId = null, - ): void { + public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void + { $current = $this->postRepository->findById($id); if ($current === null) { @@ -168,10 +106,9 @@ final class PostService implements PostServiceInterface $sanitizedContent = $this->htmlSanitizer->sanitize($content); $post = new Post($id, $title, $sanitizedContent); - - $slugToUse = $current->getStoredSlug(); - $newSlugInput = trim($newSlugInput); - $cleanSlugInput = $this->normalizeSlugInput($newSlugInput); + $slugToUse = $current->getStoredSlug(); + $newSlugInput = trim($newSlugInput); + $cleanSlugInput = $this->normalizeSlugInput($newSlugInput); if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) { $slugToUse = $this->generateUniqueSlug($cleanSlugInput, $id); @@ -184,27 +121,38 @@ final class PostService implements PostServiceInterface } } - /** - * Recherche des articles en plein texte via FTS5. - * - * @param string $query La saisie utilisateur brute - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * @param int|null $authorId Filtre optionnel par identifiant d'auteur - * - * @return Post[] - */ public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array { return $this->postRepository->search($query, $categoryId, $authorId); } /** - * Supprime un article. - * - * @param int $id Identifiant de l'article à supprimer - * - * @throws NotFoundException Si l'article n'existe plus au moment de la suppression + * @return PaginatedResult */ + public function searchPostsPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult + { + $page = max(1, $page); + $total = $this->postRepository->countSearch($query, $categoryId, $authorId); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->postRepository->searchPage($query, $perPage, $offset, $categoryId, $authorId), + $total, + $page, + $perPage, + ); + } + + public function countMediaUsages(string $url): int + { + return $this->postRepository->countByEmbeddedMediaUrl($url); + } + + public function findMediaUsages(string $url, int $limit = 5): array + { + return $this->postRepository->findByEmbeddedMediaUrl($url, $limit); + } + public function deletePost(int $id): void { $affected = $this->postRepository->delete($id); @@ -214,29 +162,11 @@ final class PostService implements PostServiceInterface } } - /** - * Nettoie une saisie utilisateur pour en faire un slug valide. - * - * Délègue à SlugHelper::generate() — voir sa documentation pour le détail - * de l'algorithme. - * - * @param string $input La valeur brute saisie par l'utilisateur - * - * @return string Le slug nettoyé, ou '' si invalide - */ private function normalizeSlugInput(string $input): string { return SlugHelper::generate($input); } - /** - * Génère un slug unique en ajoutant un suffixe numérique si nécessaire. - * - * @param string $baseSlug Le slug de base généré depuis le titre - * @param int|null $excludeId Identifiant à exclure lors de la vérification (mise à jour) - * - * @return string Le slug garanti unique - */ private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string { $slug = $baseSlug; diff --git a/src/Post/PostServiceInterface.php b/src/Post/PostServiceInterface.php index 20453ac..5c4564c 100644 --- a/src/Post/PostServiceInterface.php +++ b/src/Post/PostServiceInterface.php @@ -4,116 +4,49 @@ declare(strict_types=1); namespace App\Post; use App\Shared\Exception\NotFoundException; +use App\Shared\Pagination\PaginatedResult; -/** - * Contrat du service de gestion des articles. - * - * Permet de mocker le service dans les tests unitaires sans dépendre - * de la classe concrète finale PostService. - */ interface PostServiceInterface { - /** - * Retourne tous les articles publiés, avec un filtre optionnel par catégorie. - * - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * - * @return Post[] - */ + /** @return Post[] */ public function getAllPosts(?int $categoryId = null): array; /** - * Retourne les articles les plus récents. - * - * @param int $limit Nombre maximum d'articles à retourner (défaut : 20) - * - * @return Post[] + * @return PaginatedResult */ + public function getAllPostsPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult; + + /** @return Post[] */ public function getRecentPosts(int $limit = 20): array; - /** - * Retourne les articles d'un auteur donné. - * - * @param int $userId Identifiant de l'auteur - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * - * @return Post[] - */ + /** @return Post[] */ public function getPostsByUserId(int $userId, ?int $categoryId = null): array; /** - * Retourne un article par son slug URL. - * - * @param string $slug Le slug URL de l'article - * - * @return Post L'article avec contenu sûr - * - * @throws NotFoundException Si aucun article ne correspond au slug + * @return PaginatedResult */ + public function getPostsByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult; + public function getPostBySlug(string $slug): Post; - /** - * Retourne un article par son identifiant. - * - * @param int $id Identifiant de l'article - * - * @return Post L'article avec son contenu - * - * @throws NotFoundException Si aucun article ne correspond à cet identifiant - */ public function getPostById(int $id): Post; - /** - * Crée un nouvel article. - * - * @param string $title Titre de l'article - * @param string $content Contenu HTML brut (sera sanitisé) - * @param int $authorId Identifiant de l'auteur - * @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie) - * - * @return int L'identifiant de l'article créé - * - * @throws \InvalidArgumentException Si le titre ou le contenu sont invalides - */ public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int; - /** - * Met à jour un article existant. - * - * @param int $id Identifiant de l'article à modifier - * @param string $title Nouveau titre - * @param string $content Nouveau contenu HTML brut (sera sanitisé) - * @param string $newSlugInput Nouveau slug souhaité (vide = conserver l'actuel) - * @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie) - * - * @throws NotFoundException Si l'article n'existe plus - * @throws \InvalidArgumentException Si le titre ou le contenu sont invalides - */ - public function updatePost( - int $id, - string $title, - string $content, - string $newSlugInput = '', - ?int $categoryId = null, - ): void; + public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void; - /** - * Recherche des articles par mots-clés dans le titre, le contenu et l'auteur. - * - * @param string $query La saisie utilisateur brute - * @param int|null $categoryId Filtre optionnel par identifiant de catégorie - * @param int|null $authorId Filtre optionnel par identifiant d'auteur - * - * @return Post[] - */ + /** @return Post[] */ public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array; /** - * Supprime un article. - * - * @param int $id Identifiant de l'article à supprimer - * - * @throws NotFoundException Si l'article n'existe plus au moment de la suppression + * @return PaginatedResult */ + public function searchPostsPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult; + + public function countMediaUsages(string $url): int; + + /** @return Post[] */ + public function findMediaUsages(string $url, int $limit = 5): array; + public function deletePost(int $id): void; } diff --git a/src/Shared/Http/SessionManager.php b/src/Shared/Http/SessionManager.php index 543a23c..6f8319e 100644 --- a/src/Shared/Http/SessionManager.php +++ b/src/Shared/Http/SessionManager.php @@ -3,45 +3,21 @@ declare(strict_types=1); namespace App\Shared\Http; -/** - * Gestionnaire de session. - * - * Centralise toutes les manipulations de $_SESSION pour éviter - * les accès directs au superglobal depuis les services métier. - * Utilisé par AuthService pour la gestion de l'authentification. - */ final class SessionManager implements SessionManagerInterface { - /** - * Stocke l'identifiant, le nom et le rôle de l'utilisateur connecté en session. - * - * Régénère l'identifiant de session avant d'écrire les données utilisateur - * pour prévenir la fixation de session : un attaquant qui connaîtrait - * l'ID de session anonyme ne peut pas hériter de la session authentifiée. - * - * @param int $userId Identifiant de l'utilisateur - * @param string $username Nom d'utilisateur - * @param string $role Rôle de l'utilisateur : 'user', 'editor' ou 'admin' - * @return void - */ public function setUser(int $userId, string $username, string $role = 'user'): void { - // Régénération de l'ID de session pour prévenir la fixation de session. - // Le guard évite une notice PHP en contexte CLI (tests unitaires). if (session_status() === PHP_SESSION_ACTIVE) { session_regenerate_id(true); } - $_SESSION['user_id'] = $userId; - $_SESSION['username'] = $username; - $_SESSION['role'] = $role; + $_SESSION['user_id'] = $userId; + $_SESSION['username'] = $username; + $_SESSION['role'] = $role; + $_SESSION['session_established_at'] = time(); + $_SESSION['session_last_regenerated_at'] = time(); } - /** - * Retourne l'identifiant de l'utilisateur connecté. - * - * @return int|null L'identifiant, ou null si aucune session active - */ public function getUserId(): ?int { return isset($_SESSION['user_id']) && $_SESSION['user_id'] !== '' @@ -49,44 +25,21 @@ final class SessionManager implements SessionManagerInterface : null; } - /** - * Vérifie si une session utilisateur est active. - * - * @return bool True si un utilisateur est connecté - */ public function isAuthenticated(): bool { return $this->getUserId() !== null; } - /** - * Vérifie si l'utilisateur connecté est administrateur. - * - * @return bool True si l'utilisateur a le rôle 'admin' - */ public function isAdmin(): bool { return ($_SESSION['role'] ?? '') === 'admin'; } - /** - * Vérifie si l'utilisateur connecté est éditeur. - * - * @return bool True si l'utilisateur a le rôle 'editor' - */ public function isEditor(): bool { return ($_SESSION['role'] ?? '') === 'editor'; } - /** - * Détruit la session courante. - * - * Vide les données, expire le cookie de session (avec les mêmes attributs - * que lors de sa création) et détruit la session PHP. - * L'attribut SameSite=Lax limite l'envoi du cookie aux navigations - * de premier niveau, réduisant l'exposition aux attaques CSRF. - */ public function destroy(): void { $_SESSION = []; diff --git a/src/Shared/Pagination/PaginatedResult.php b/src/Shared/Pagination/PaginatedResult.php new file mode 100644 index 0000000..b25f6a1 --- /dev/null +++ b/src/Shared/Pagination/PaginatedResult.php @@ -0,0 +1,65 @@ + */ + private readonly array $items; + + /** + * @param array $items + */ + public function __construct( + array $items, + private readonly int $total, + private readonly int $currentPage, + private readonly int $perPage, + ) { + $this->items = $items; + } + + /** + * @return array + */ + public function getItems(): array + { + return $this->items; + } + + public function getTotal(): int + { + return $this->total; + } + + public function getCurrentPage(): int + { + return $this->currentPage; + } + + public function getPerPage(): int + { + return $this->perPage; + } + + public function getTotalPages(): int + { + return max(1, (int) ceil($this->total / max(1, $this->perPage))); + } + + public function hasPreviousPage(): bool + { + return $this->currentPage > 1; + } + + public function hasNextPage(): bool + { + return $this->currentPage < $this->getTotalPages(); + } +} diff --git a/src/Shared/Pagination/PaginationPresenter.php b/src/Shared/Pagination/PaginationPresenter.php new file mode 100644 index 0000000..85bc0fb --- /dev/null +++ b/src/Shared/Pagination/PaginationPresenter.php @@ -0,0 +1,74 @@ + $queryParams + */ + public static function resolvePage(array $queryParams): int + { + $rawPage = $queryParams['page'] ?? 1; + $page = filter_var($rawPage, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + + return $page === false ? 1 : (int) $page; + } + + /** + * @param PaginatedResult $result + * @return array + */ + public static function fromRequest(ServerRequestInterface $request, PaginatedResult $result): array + { + $totalPages = $result->getTotalPages(); + $currentPage = min($result->getCurrentPage(), $totalPages); + $baseQuery = $request->getQueryParams(); + unset($baseQuery['page']); + + $pages = []; + $start = max(1, $currentPage - 2); + $end = min($totalPages, $currentPage + 2); + + for ($page = $start; $page <= $end; ++$page) { + $pages[] = [ + 'number' => $page, + 'url' => self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $page), + 'current' => $page === $currentPage, + ]; + } + + return [ + 'currentPage' => $currentPage, + 'perPage' => $result->getPerPage(), + 'total' => $result->getTotal(), + 'totalPages' => $totalPages, + 'hasPrevious' => $currentPage > 1, + 'hasNext' => $currentPage < $totalPages, + 'previousUrl' => $currentPage > 1 + ? self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $currentPage - 1) + : null, + 'nextUrl' => $currentPage < $totalPages + ? self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $currentPage + 1) + : null, + 'pages' => $pages, + ]; + } + + /** + * @param array $query + */ + private static function buildPageUrl(string $path, array $query, int $page): string + { + if ($page > 1) { + $query['page'] = $page; + } + + $queryString = http_build_query($query); + + return $queryString === '' ? $path : $path . '?' . $queryString; + } +} diff --git a/src/User/Exception/RoleAssignmentNotAllowedException.php b/src/User/Exception/RoleAssignmentNotAllowedException.php new file mode 100644 index 0000000..6a7ee71 --- /dev/null +++ b/src/User/Exception/RoleAssignmentNotAllowedException.php @@ -0,0 +1,17 @@ + $data Données issues de la base de données - * - * @return self L'instance hydratée + * @param array $data */ public static function fromArray(array $data): self { @@ -70,105 +42,69 @@ final class User } /** - * Retourne l'identifiant de l'utilisateur. - * - * @return int L'identifiant en base (0 si non encore persisté) + * @return string[] */ + public static function allRoles(): array + { + return [self::ROLE_USER, self::ROLE_EDITOR, self::ROLE_ADMIN]; + } + + /** + * @return string[] + */ + public static function assignableRoles(): array + { + return [self::ROLE_USER, self::ROLE_EDITOR]; + } + public function getId(): int { return $this->id; } - /** - * Retourne le nom d'utilisateur. - * - * @return string Le nom d'utilisateur normalisé en minuscules - */ public function getUsername(): string { return $this->username; } - /** - * Retourne l'adresse e-mail. - * - * @return string L'adresse e-mail normalisée en minuscules - */ public function getEmail(): string { return $this->email; } - /** - * Retourne le hash bcrypt du mot de passe. - * - * @return string Le hash bcrypt - */ public function getPasswordHash(): string { return $this->passwordHash; } - /** - * Retourne le rôle de l'utilisateur. - * - * @return string 'user', 'editor' ou 'admin' - */ public function getRole(): string { return $this->role; } - /** - * Indique si l'utilisateur a le rôle administrateur. - * - * @return bool True si l'utilisateur est administrateur - */ public function isAdmin(): bool { return $this->role === self::ROLE_ADMIN; } - /** - * Indique si l'utilisateur a le rôle éditeur. - * - * @return bool True si l'utilisateur est éditeur - */ public function isEditor(): bool { return $this->role === self::ROLE_EDITOR; } - /** - * Retourne la date de création du compte. - * - * @return DateTime La date de création - */ public function getCreatedAt(): DateTime { return $this->createdAt; } - /** - * Valide les données de l'utilisateur. - * - * @throws \InvalidArgumentException Si le nom d'utilisateur fait moins de 3 ou plus de 50 caractères - * @throws \InvalidArgumentException Si l'adresse e-mail est invalide ou vide - * @throws \InvalidArgumentException Si le hash du mot de passe est vide - * @throws \InvalidArgumentException Si le rôle n'est pas une valeur autorisée - */ private function validate(): void { if (mb_strlen($this->username) < 3) { - throw new \InvalidArgumentException( - "Le nom d'utilisateur doit contenir au moins 3 caractères" - ); + throw new \InvalidArgumentException("Le nom d'utilisateur doit contenir au moins 3 caractères"); } if (mb_strlen($this->username) > 50) { - throw new \InvalidArgumentException( - "Le nom d'utilisateur ne peut pas dépasser 50 caractères" - ); + throw new \InvalidArgumentException("Le nom d'utilisateur ne peut pas dépasser 50 caractères"); } if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) { @@ -176,15 +112,12 @@ final class User } if ($this->passwordHash === '') { - throw new \InvalidArgumentException( - 'Le hash du mot de passe ne peut pas être vide' - ); + throw new \InvalidArgumentException('Le hash du mot de passe ne peut pas être vide'); } - $validRoles = [self::ROLE_USER, self::ROLE_EDITOR, self::ROLE_ADMIN]; - if (!in_array($this->role, $validRoles, true)) { + if (!in_array($this->role, self::allRoles(), true)) { throw new \InvalidArgumentException( - "Le rôle '{$this->role}' est invalide. Valeurs autorisées : " . implode(', ', $validRoles) + "Le rôle '{$this->role}' est invalide. Valeurs autorisées : " . implode(', ', self::allRoles()) ); } } diff --git a/src/User/UserController.php b/src/User/UserController.php index 58f5e18..e8e07f6 100644 --- a/src/User/UserController.php +++ b/src/User/UserController.php @@ -5,33 +5,20 @@ namespace App\User; use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; +use App\Shared\Pagination\PaginationPresenter; use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateUsernameException; use App\User\Exception\InvalidRoleException; +use App\User\Exception\RoleAssignmentNotAllowedException; use App\User\Exception\WeakPasswordException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Views\Twig; -/** - * Contrôleur pour la gestion des utilisateurs en administration. - * - * Accessible uniquement aux administrateurs (AdminMiddleware). - * Gère la liste, la création, la modification de rôle et la suppression des comptes. - * Toute la logique de persistance est déléguée à UserService. - * - * Règles de protection communes : - * - Le compte administrateur (role = 'admin') ne peut pas être supprimé ni rétrogradé - * - Un administrateur ne peut pas supprimer son propre compte ni changer son propre rôle - */ final class UserController { - /** - * @param Twig $view Moteur de templates Twig - * @param UserServiceInterface $userService Service de gestion des utilisateurs - * @param FlashServiceInterface $flash Service de messages flash - * @param SessionManagerInterface $sessionManager Gestionnaire de session - */ + private const PER_PAGE = 15; + public function __construct( private readonly Twig $view, private readonly UserServiceInterface $userService, @@ -40,84 +27,47 @@ final class UserController ) { } - /** - * Affiche la liste de tous les utilisateurs. - * - * Passe l'identifiant de l'utilisateur courant à la vue - * pour conditionner l'affichage du bouton de suppression. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response La vue admin/users/index.twig - */ public function index(Request $req, Response $res): Response { + $page = PaginationPresenter::resolvePage($req->getQueryParams()); + $paginated = $this->userService->findPaginated($page, self::PER_PAGE); + return $this->view->render($res, 'admin/users/index.twig', [ - 'users' => $this->userService->findAll(), + 'users' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), 'currentUserId' => $this->sessionManager->getUserId(), + 'assignableRoles' => User::assignableRoles(), 'error' => $this->flash->get('user_error'), 'success' => $this->flash->get('user_success'), ]); } - /** - * Affiche le formulaire de création d'un utilisateur. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response La vue admin/users/form.twig - */ public function showCreate(Request $req, Response $res): Response { return $this->view->render($res, 'admin/users/form.twig', [ + 'assignableRoles' => User::assignableRoles(), 'error' => $this->flash->get('user_error'), ]); } - /** - * Traite la soumission du formulaire de création d'utilisateur. - * - * Vérifie que les mots de passe correspondent avant de déléguer - * la création à UserService. En cas d'erreur, redirige vers le - * formulaire avec un message flash. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response Redirection vers /admin/users en cas de succès, - * ou vers /admin/users/create en cas d'erreur - */ public function create(Request $req, Response $res): Response { - /** @var array $data */ $data = (array) $req->getParsedBody(); $username = trim((string) ($data['username'] ?? '')); $email = trim((string) ($data['email'] ?? '')); $password = trim((string) ($data['password'] ?? '')); $confirm = trim((string) ($data['password_confirm'] ?? '')); - - // Restreindre les rôles assignables depuis le formulaire. - // Le rôle 'admin' est exclu : il ne peut être attribué que directement - // en base de données, pour éviter qu'un admin ne crée d'autres admins - // en manipulant la requête HTTP. - $allowedRoles = [User::ROLE_USER, User::ROLE_EDITOR]; - $rawRole = trim((string) ($data['role'] ?? '')); - $role = in_array($rawRole, $allowedRoles, true) - ? $rawRole - : User::ROLE_USER; + $rawRole = trim((string) ($data['role'] ?? '')); + $role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : User::ROLE_USER; if ($password !== $confirm) { $this->flash->set('user_error', 'Les mots de passe ne correspondent pas'); - return $res->withHeader('Location', '/admin/users/create')->withStatus(302); } try { $this->userService->createUser($username, $email, $password, $role); $this->flash->set('user_success', "L'utilisateur « {$username} » a été créé avec succès"); - return $res->withHeader('Location', '/admin/users')->withStatus(302); } catch (DuplicateUsernameException) { $this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris"); @@ -125,7 +75,7 @@ final class UserController $this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée'); } catch (WeakPasswordException) { $this->flash->set('user_error', 'Le mot de passe doit contenir au moins 8 caractères'); - } catch (InvalidRoleException $e) { + } catch (InvalidRoleException|RoleAssignmentNotAllowedException $e) { $this->flash->set('user_error', $e->getMessage()); } catch (\Throwable) { $this->flash->set('user_error', "Une erreur inattendue s'est produite"); @@ -135,18 +85,7 @@ final class UserController } /** - * Met à jour le rôle d'un utilisateur. - * - * La modification est refusée dans trois cas : - * - l'utilisateur cible est introuvable - * - l'administrateur connecté tente de modifier son propre rôle - * - l'utilisateur cible est déjà administrateur - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * @param array $args Les paramètres de route (id) - * - * @return Response Redirection vers /admin/users dans tous les cas + * @param array $args */ public function updateRole(Request $req, Response $res, array $args): Response { @@ -155,53 +94,40 @@ final class UserController if ($user === null) { $this->flash->set('user_error', 'Utilisateur introuvable'); - return $res->withHeader('Location', '/admin/users')->withStatus(302); } if ($id === $this->sessionManager->getUserId()) { $this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle'); - return $res->withHeader('Location', '/admin/users')->withStatus(302); } if ($user->isAdmin()) { - $this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié'); - + $this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié depuis l\'interface'); return $res->withHeader('Location', '/admin/users')->withStatus(302); } - $allowedRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN]; - /** @var array $body */ - $body = (array) $req->getParsedBody(); - $rawRole = trim((string) ($body['role'] ?? '')); - $role = in_array($rawRole, $allowedRoles, true) ? $rawRole : null; + $body = (array) $req->getParsedBody(); + $rawRole = trim((string) ($body['role'] ?? '')); + $role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : null; if ($role === null) { $this->flash->set('user_error', 'Rôle invalide'); - return $res->withHeader('Location', '/admin/users')->withStatus(302); } - $this->userService->updateRole($id, $role); - $this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour"); + try { + $this->userService->updateRole($id, $role); + $this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour"); + } catch (InvalidRoleException|RoleAssignmentNotAllowedException $e) { + $this->flash->set('user_error', $e->getMessage()); + } return $res->withHeader('Location', '/admin/users')->withStatus(302); } /** - * Supprime un utilisateur. - * - * La suppression est refusée dans trois cas : - * - l'utilisateur cible est introuvable - * - l'utilisateur cible est administrateur (role = 'admin') - * - l'administrateur connecté tente de supprimer son propre compte - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * @param array $args Les paramètres de route (id) - * - * @return Response Redirection vers /admin/users dans tous les cas + * @param array $args */ public function delete(Request $req, Response $res, array $args): Response { @@ -210,19 +136,16 @@ final class UserController if ($user === null) { $this->flash->set('user_error', 'Utilisateur introuvable'); - return $res->withHeader('Location', '/admin/users')->withStatus(302); } if ($user->isAdmin()) { $this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé'); - return $res->withHeader('Location', '/admin/users')->withStatus(302); } if ($id === $this->sessionManager->getUserId()) { $this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte'); - return $res->withHeader('Location', '/admin/users')->withStatus(302); } diff --git a/src/User/UserRepository.php b/src/User/UserRepository.php index 471cf5f..51c1364 100644 --- a/src/User/UserRepository.php +++ b/src/User/UserRepository.php @@ -5,101 +5,75 @@ namespace App\User; use PDO; -/** - * Dépôt pour la persistance des utilisateurs. - * - * Responsabilité unique : exécuter les requêtes SQL liées à la table `users` - * et retourner des instances de User hydratées. - */ final class UserRepository implements UserRepositoryInterface { - /** - * @param PDO $db Instance de connexion à la base de données - */ public function __construct(private readonly PDO $db) { } - /** - * Retourne tous les utilisateurs triés par date de création. - * - * @return User[] La liste des utilisateurs - */ public function findAll(): array { $stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC'); if ($stmt === false) { throw new \RuntimeException('La requête SELECT sur users a échoué.'); } - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - return array_map(fn ($row) => User::fromArray($row), $rows); + return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function findPage(int $limit, int $offset): array + { + $stmt = $this->db->prepare('SELECT * FROM users ORDER BY created_at ASC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countAll(): int + { + $stmt = $this->db->query('SELECT COUNT(*) FROM users'); + if ($stmt === false) { + throw new \RuntimeException('La requête COUNT sur users a échoué.'); + } + + return (int) ($stmt->fetchColumn() ?: 0); } - /** - * Trouve un utilisateur par son identifiant. - * - * @param int $id Identifiant de l'utilisateur - * - * @return User|null L'utilisateur trouvé, ou null s'il n'existe pas - */ public function findById(int $id): ?User { $stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id'); $stmt->execute([':id' => $id]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? User::fromArray($row) : null; } - /** - * Trouve un utilisateur par son nom d'utilisateur (insensible à la casse). - * - * @param string $username Nom d'utilisateur normalisé en minuscules - * - * @return User|null L'utilisateur trouvé, ou null s'il n'existe pas - */ public function findByUsername(string $username): ?User { $stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username'); $stmt->execute([':username' => $username]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? User::fromArray($row) : null; } - /** - * Trouve un utilisateur par son adresse e-mail (insensible à la casse). - * - * @param string $email Adresse e-mail normalisée en minuscules - * - * @return User|null L'utilisateur trouvé, ou null s'il n'existe pas - */ public function findByEmail(string $email): ?User { $stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email'); $stmt->execute([':email' => $email]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? User::fromArray($row) : null; } - /** - * Persiste un nouvel utilisateur en base de données. - * - * @param User $user L'utilisateur à créer - * - * @return int L'identifiant généré par la base de données - */ public function create(User $user): int { - $stmt = $this->db->prepare(' - INSERT INTO users (username, email, password_hash, role, created_at) - VALUES (:username, :email, :password_hash, :role, :created_at) - '); + $stmt = $this->db->prepare( + 'INSERT INTO users (username, email, password_hash, role, created_at) + VALUES (:username, :email, :password_hash, :role, :created_at)' + ); $stmt->execute([ ':username' => $user->getUsername(), @@ -112,36 +86,18 @@ final class UserRepository implements UserRepositoryInterface return (int) $this->db->lastInsertId(); } - /** - * Met à jour le hash du mot de passe d'un utilisateur. - * - * @param int $id Identifiant de l'utilisateur - * @param string $newHash Nouveau hash bcrypt - */ public function updatePassword(int $id, string $newHash): void { $stmt = $this->db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id'); $stmt->execute([':password_hash' => $newHash, ':id' => $id]); } - /** - * Met à jour le rôle d'un utilisateur. - * - * @param int $id Identifiant de l'utilisateur - * @param string $role Nouveau rôle : 'user', 'editor' ou 'admin' - */ public function updateRole(int $id, string $role): void { $stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id'); $stmt->execute([':role' => $role, ':id' => $id]); } - /** - * Supprime un utilisateur de la base de données. - * - * @param int $id Identifiant de l'utilisateur à supprimer - * @return void - */ public function delete(int $id): void { $stmt = $this->db->prepare('DELETE FROM users WHERE id = :id'); diff --git a/src/User/UserRepositoryInterface.php b/src/User/UserRepositoryInterface.php index a7fe14f..cd6d8bb 100644 --- a/src/User/UserRepositoryInterface.php +++ b/src/User/UserRepositoryInterface.php @@ -3,78 +3,27 @@ declare(strict_types=1); namespace App\User; -/** - * Contrat de persistance des utilisateurs. - * - * Découple les services métier de l'implémentation concrète (PDO/SQLite), - * facilitant les mocks dans les tests et un éventuel changement de stockage. - */ interface UserRepositoryInterface { - /** - * Retourne tous les utilisateurs triés par date de création. - * - * @return User[] - */ + /** @return User[] */ public function findAll(): array; - /** - * Trouve un utilisateur par son identifiant. - * - * @param int $id Identifiant de l'utilisateur - * - * @return User|null L'utilisateur trouvé, ou null s'il n'existe pas - */ + /** @return User[] */ + public function findPage(int $limit, int $offset): array; + + public function countAll(): int; + public function findById(int $id): ?User; - /** - * Trouve un utilisateur par son nom d'utilisateur. - * - * @param string $username Nom d'utilisateur normalisé en minuscules - * - * @return User|null L'utilisateur trouvé, ou null s'il n'existe pas - */ public function findByUsername(string $username): ?User; - /** - * Trouve un utilisateur par son adresse e-mail. - * - * @param string $email Adresse e-mail normalisée en minuscules - * - * @return User|null L'utilisateur trouvé, ou null s'il n'existe pas - */ public function findByEmail(string $email): ?User; - /** - * Persiste un nouvel utilisateur en base de données. - * - * @param User $user L'utilisateur à créer - * - * @return int L'identifiant généré par la base de données - */ public function create(User $user): int; - /** - * Met à jour le hash du mot de passe d'un utilisateur. - * - * @param int $id Identifiant de l'utilisateur - * @param string $newHash Nouveau hash bcrypt - */ public function updatePassword(int $id, string $newHash): void; - /** - * Met à jour le rôle d'un utilisateur. - * - * @param int $id Identifiant de l'utilisateur - * @param string $role Nouveau rôle : 'user', 'editor' ou 'admin' - */ public function updateRole(int $id, string $role): void; - /** - * Supprime un utilisateur de la base de données. - * - * @param int $id Identifiant de l'utilisateur à supprimer - * @return void - */ public function delete(int $id): void; } diff --git a/src/User/UserService.php b/src/User/UserService.php index f4ad351..030bd17 100644 --- a/src/User/UserService.php +++ b/src/User/UserService.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace App\User; use App\Shared\Exception\NotFoundException; +use App\Shared\Pagination\PaginatedResult; use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateUsernameException; use App\User\Exception\InvalidRoleException; +use App\User\Exception\RoleAssignmentNotAllowedException; use App\User\Exception\WeakPasswordException; final class UserService implements UserServiceInterface @@ -21,6 +23,23 @@ final class UserService implements UserServiceInterface return $this->userRepository->findAll(); } + /** + * @return PaginatedResult + */ + public function findPaginated(int $page, int $perPage): PaginatedResult + { + $page = max(1, $page); + $total = $this->userRepository->countAll(); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->userRepository->findPage($perPage, $offset), + $total, + $page, + $perPage, + ); + } + public function findById(int $id): ?User { return $this->userRepository->findById($id); @@ -34,7 +53,7 @@ final class UserService implements UserServiceInterface public function updateRole(int $id, string $role): void { - $this->assertValidRole($role); + $this->assertRoleCanBeAssigned($role); $this->requireExistingUser($id); $this->userRepository->updateRole($id, $role); } @@ -45,7 +64,7 @@ final class UserService implements UserServiceInterface $email = mb_strtolower(trim($email)); $plainPassword = trim($plainPassword); - $this->assertValidRole($role); + $this->assertRoleCanBeAssigned($role); if ($this->userRepository->findByUsername($username)) { throw new DuplicateUsernameException($username); @@ -67,13 +86,15 @@ final class UserService implements UserServiceInterface return $user; } - private function assertValidRole(string $role): void + private function assertRoleCanBeAssigned(string $role): void { - $validRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN]; - - if (!in_array($role, $validRoles, true)) { + if (!in_array($role, User::allRoles(), true)) { throw new InvalidRoleException($role); } + + if (!in_array($role, User::assignableRoles(), true)) { + throw new RoleAssignmentNotAllowedException($role); + } } private function requireExistingUser(int $id): User diff --git a/src/User/UserServiceInterface.php b/src/User/UserServiceInterface.php index 846b168..2b50efd 100644 --- a/src/User/UserServiceInterface.php +++ b/src/User/UserServiceInterface.php @@ -3,67 +3,36 @@ declare(strict_types=1); namespace App\User; +use App\Shared\Pagination\PaginatedResult; use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateUsernameException; use App\User\Exception\InvalidRoleException; use App\User\Exception\WeakPasswordException; -/** - * Contrat du service de gestion des utilisateurs. - * - * Permet de mocker le service dans les tests unitaires sans dépendre - * de la classe concrète finale UserService. - */ interface UserServiceInterface { - /** - * Retourne tous les utilisateurs triés par date de création (ordre croissant). - * - * @return User[] - */ + /** @return User[] */ public function findAll(): array; /** - * Trouve un utilisateur par son identifiant. - * - * @param int $id Identifiant de l'utilisateur - * - * @return User|null L'utilisateur trouvé, ou null s'il n'existe pas + * @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult; + public function findById(int $id): ?User; - /** - * Supprime un utilisateur de la base de données. - * - * @param int $id Identifiant de l'utilisateur à supprimer - */ public function delete(int $id): void; /** - * Crée un nouveau compte utilisateur. - * - * @param string $username Nom d'utilisateur souhaité (min. 3 caractères) - * @param string $email Adresse e-mail valide - * @param string $plainPassword Mot de passe en clair (min. 8 caractères) - * @param string $role Rôle attribué : 'user', 'editor' ou 'admin' (défaut : 'user') - * - * @return User L'utilisateur créé (sans mot de passe en clair) - * - * @throws DuplicateUsernameException Si le nom d'utilisateur est déjà pris - * @throws DuplicateEmailException Si l'adresse e-mail est déjà utilisée - * @throws WeakPasswordException Si le mot de passe est trop court - * @throws InvalidRoleException Si le rôle est invalide - * @throws \InvalidArgumentException Si le nom ou l'email ne passent pas la validation + * @throws DuplicateUsernameException + * @throws DuplicateEmailException + * @throws WeakPasswordException + * @throws InvalidRoleException */ public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User; /** - * Met à jour le rôle d'un utilisateur. - * - * @param int $id Identifiant de l'utilisateur - * @param string $role Nouveau rôle : 'user', 'editor' ou 'admin' - * - * @throws InvalidRoleException Si le rôle est invalide + * @throws InvalidRoleException */ public function updateRole(int $id, string $role): void; } diff --git a/tests/Auth/AccountControllerTest.php b/tests/Auth/AccountControllerTest.php index 0114dd0..e8bd502 100644 --- a/tests/Auth/AccountControllerTest.php +++ b/tests/Auth/AccountControllerTest.php @@ -9,7 +9,7 @@ use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; use App\User\Exception\WeakPasswordException; use PHPUnit\Framework\MockObject\MockObject; -use Tests\ControllerTestCase; +use Tests\ControllerTestBase; /** * Tests unitaires pour AccountController. @@ -19,7 +19,7 @@ use Tests\ControllerTestCase; * incorrect, erreur inattendue et succès. */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -final class AccountControllerTest extends ControllerTestCase +final class AccountControllerTest extends ControllerTestBase { /** @var \Slim\Views\Twig&MockObject */ private \Slim\Views\Twig $view; diff --git a/tests/Auth/AuthControllerTest.php b/tests/Auth/AuthControllerTest.php index 7c0c7c3..78f347e 100644 --- a/tests/Auth/AuthControllerTest.php +++ b/tests/Auth/AuthControllerTest.php @@ -9,7 +9,7 @@ use App\Shared\Http\ClientIpResolver; use App\Shared\Http\FlashServiceInterface; use App\User\User; use PHPUnit\Framework\MockObject\MockObject; -use Tests\ControllerTestCase; +use Tests\ControllerTestBase; /** * Tests unitaires pour AuthController. @@ -19,7 +19,7 @@ use Tests\ControllerTestCase; * aucune base de données, aucun serveur HTTP n'est requis. */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -final class AuthControllerTest extends ControllerTestCase +final class AuthControllerTest extends ControllerTestBase { /** @var \Slim\Views\Twig&MockObject */ private \Slim\Views\Twig $view; diff --git a/tests/Auth/PasswordResetControllerTest.php b/tests/Auth/PasswordResetControllerTest.php index f8e3dd1..da67613 100644 --- a/tests/Auth/PasswordResetControllerTest.php +++ b/tests/Auth/PasswordResetControllerTest.php @@ -12,7 +12,7 @@ use App\Shared\Http\FlashServiceInterface; use App\User\Exception\WeakPasswordException; use App\User\User; use PHPUnit\Framework\MockObject\MockObject; -use Tests\ControllerTestCase; +use Tests\ControllerTestBase; /** * Tests unitaires pour PasswordResetController. @@ -29,7 +29,7 @@ use Tests\ControllerTestCase; * - reset() couvre 5 chemins de sortie (token vide, mismatch, trop court, invalide, succès) */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -final class PasswordResetControllerTest extends ControllerTestCase +final class PasswordResetControllerTest extends ControllerTestBase { /** @var \Slim\Views\Twig&MockObject */ private \Slim\Views\Twig $view; diff --git a/tests/Category/CategoryControllerTest.php b/tests/Category/CategoryControllerTest.php index 328e603..f853bbb 100644 --- a/tests/Category/CategoryControllerTest.php +++ b/tests/Category/CategoryControllerTest.php @@ -7,8 +7,9 @@ use App\Category\Category; use App\Category\CategoryController; use App\Category\CategoryServiceInterface; use App\Shared\Http\FlashServiceInterface; +use App\Shared\Pagination\PaginatedResult; use PHPUnit\Framework\MockObject\MockObject; -use Tests\ControllerTestCase; +use Tests\ControllerTestBase; /** * Tests unitaires pour CategoryController. @@ -18,7 +19,7 @@ use Tests\ControllerTestCase; * suppression avec catégorie introuvable, succès et erreur métier. */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -final class CategoryControllerTest extends ControllerTestCase +final class CategoryControllerTest extends ControllerTestBase { /** @var \Slim\Views\Twig&MockObject */ private \Slim\Views\Twig $view; @@ -51,7 +52,7 @@ final class CategoryControllerTest extends ControllerTestCase */ public function testIndexRendersWithCategories(): void { - $this->categoryService->method('findAll')->willReturn([]); + $this->categoryService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20)); $this->view->expects($this->once()) ->method('render') diff --git a/tests/ControllerTestCase.php b/tests/ControllerTestBase.php similarity index 99% rename from tests/ControllerTestCase.php rename to tests/ControllerTestBase.php index 9f4fce9..7735f5d 100644 --- a/tests/ControllerTestCase.php +++ b/tests/ControllerTestBase.php @@ -19,7 +19,7 @@ use Slim\Psr7\Response as SlimResponse; * sans passer par le routeur Slim — les middlewares sont testés séparément. */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -abstract class ControllerTestCase extends TestCase +abstract class ControllerTestBase extends TestCase { // ── Factories ──────────────────────────────────────────────────── diff --git a/tests/Media/MediaControllerTest.php b/tests/Media/MediaControllerTest.php index adf5e23..ce04bef 100644 --- a/tests/Media/MediaControllerTest.php +++ b/tests/Media/MediaControllerTest.php @@ -11,9 +11,10 @@ use App\Media\MediaController; use App\Media\MediaServiceInterface; use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; +use App\Shared\Pagination\PaginatedResult; use PHPUnit\Framework\MockObject\MockObject; use Psr\Http\Message\UploadedFileInterface; -use Tests\ControllerTestCase; +use Tests\ControllerTestBase; /** * Tests unitaires pour MediaController. @@ -24,7 +25,7 @@ use Tests\ControllerTestCase; * - delete : introuvable, non-propriétaire, succès propriétaire, succès admin */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -final class MediaControllerTest extends ControllerTestCase +final class MediaControllerTest extends ControllerTestBase { /** @var \Slim\Views\Twig&MockObject */ private \Slim\Views\Twig $view; @@ -65,8 +66,8 @@ final class MediaControllerTest extends ControllerTestCase $this->sessionManager->method('isAdmin')->willReturn(true); $this->sessionManager->method('isEditor')->willReturn(false); - $this->mediaService->expects($this->once())->method('findAll')->willReturn([]); - $this->mediaService->expects($this->never())->method('findByUserId'); + $this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12)); + $this->mediaService->expects($this->never())->method('findByUserIdPaginated'); $this->view->expects($this->once()) ->method('render') @@ -86,8 +87,8 @@ final class MediaControllerTest extends ControllerTestCase $this->sessionManager->method('isAdmin')->willReturn(false); $this->sessionManager->method('isEditor')->willReturn(true); - $this->mediaService->expects($this->once())->method('findAll')->willReturn([]); - $this->mediaService->expects($this->never())->method('findByUserId'); + $this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12)); + $this->mediaService->expects($this->never())->method('findByUserIdPaginated'); $res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse()); @@ -103,8 +104,8 @@ final class MediaControllerTest extends ControllerTestCase $this->sessionManager->method('isEditor')->willReturn(false); $this->sessionManager->method('getUserId')->willReturn(42); - $this->mediaService->expects($this->once())->method('findByUserId')->with(42)->willReturn([]); - $this->mediaService->expects($this->never())->method('findAll'); + $this->mediaService->expects($this->once())->method('findByUserIdPaginated')->with(42, 1, 12)->willReturn(new PaginatedResult([], 0, 1, 12)); + $this->mediaService->expects($this->never())->method('findPaginated'); $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse()); } diff --git a/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php b/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php index 468c505..a818a10 100644 --- a/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php +++ b/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php @@ -6,6 +6,7 @@ namespace Tests\Media; use App\Media\Media; use App\Media\MediaRepositoryInterface; use App\Media\MediaService; +use App\Post\PostRepositoryInterface; use PDOException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -19,6 +20,8 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase /** @var MediaRepositoryInterface&MockObject */ private MediaRepositoryInterface $repository; + private PostRepositoryInterface $postRepository; + private string $uploadDir; private MediaService $service; @@ -26,10 +29,11 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase protected function setUp(): void { $this->repository = $this->createMock(MediaRepositoryInterface::class); + $this->postRepository = $this->createMock(PostRepositoryInterface::class); $this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true); @mkdir($this->uploadDir, 0755, true); - $this->service = new MediaService($this->repository, $this->uploadDir, '/media', 5 * 1024 * 1024); + $this->service = new MediaService($this->repository, $this->postRepository, $this->uploadDir, '/media', 5 * 1024 * 1024); } protected function tearDown(): void @@ -49,8 +53,8 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase $duplicate = new Media(77, 'existing.gif', '/media/existing.gif', $hash, 1); $this->repository->expects($this->exactly(2)) - ->method('findByHash') - ->with($hash) + ->method('findByHashForUser') + ->with($hash, 1) ->willReturnOnConsecutiveCalls(null, $duplicate); $this->repository->expects($this->once()) diff --git a/tests/Media/MediaServiceEdgeCasesTest.php b/tests/Media/MediaServiceEdgeCasesTest.php index 147aa5d..8a4ea9c 100644 --- a/tests/Media/MediaServiceEdgeCasesTest.php +++ b/tests/Media/MediaServiceEdgeCasesTest.php @@ -3,26 +3,27 @@ declare(strict_types=1); namespace Tests\Media; -use App\Media\MediaService; -use App\Media\MediaRepositoryInterface; use App\Media\Exception\FileTooLargeException; use App\Media\Exception\StorageException; +use App\Media\MediaRepositoryInterface; +use App\Media\MediaService; +use App\Post\PostRepositoryInterface; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\UploadedFileInterface; use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UploadedFileInterface; #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] - final class MediaServiceEdgeCasesTest extends TestCase { public function testRejectsWhenSizeUnknown(): void { $repo = $this->createMock(MediaRepositoryInterface::class); + $postRepo = $this->createMock(PostRepositoryInterface::class); $file = $this->createMock(UploadedFileInterface::class); $file->method('getSize')->willReturn(null); - $service = new MediaService($repo, '/tmp', '/media', 1000); + $service = new MediaService($repo, $postRepo, '/tmp', '/media', 1000); $this->expectException(StorageException::class); $service->store($file, 1); @@ -31,6 +32,7 @@ final class MediaServiceEdgeCasesTest extends TestCase public function testRejectsWhenFileTooLarge(): void { $repo = $this->createMock(MediaRepositoryInterface::class); + $postRepo = $this->createMock(PostRepositoryInterface::class); $stream = $this->createMock(StreamInterface::class); $stream->method('getMetadata')->willReturn('/tmp/file'); @@ -39,7 +41,7 @@ final class MediaServiceEdgeCasesTest extends TestCase $file->method('getSize')->willReturn(999999); $file->method('getStream')->willReturn($stream); - $service = new MediaService($repo, '/tmp', '/media', 100); + $service = new MediaService($repo, $postRepo, '/tmp', '/media', 100); $this->expectException(FileTooLargeException::class); $service->store($file, 1); diff --git a/tests/Media/MediaServiceInvalidMimeTest.php b/tests/Media/MediaServiceInvalidMimeTest.php index 0c2a655..ba2a1f3 100644 --- a/tests/Media/MediaServiceInvalidMimeTest.php +++ b/tests/Media/MediaServiceInvalidMimeTest.php @@ -6,17 +6,18 @@ namespace Tests\Media; use App\Media\Exception\InvalidMimeTypeException; use App\Media\MediaRepositoryInterface; use App\Media\MediaService; +use App\Post\PostRepositoryInterface; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\UploadedFileInterface; use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UploadedFileInterface; #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] - final class MediaServiceInvalidMimeTest extends TestCase { public function testRejectsNonImageContentEvenWithImageLikeFilename(): void { $repo = $this->createMock(MediaRepositoryInterface::class); + $postRepo = $this->createMock(PostRepositoryInterface::class); $tmpFile = tempnam(sys_get_temp_dir(), 'upload_'); self::assertNotFalse($tmpFile); @@ -30,7 +31,7 @@ final class MediaServiceInvalidMimeTest extends TestCase $file->method('getStream')->willReturn($stream); $file->method('getClientFilename')->willReturn('photo.png'); - $service = new MediaService($repo, sys_get_temp_dir(), '/media', 500000); + $service = new MediaService($repo, $postRepo, sys_get_temp_dir(), '/media', 500000); try { $this->expectException(InvalidMimeTypeException::class); diff --git a/tests/Media/MediaServiceInvalidTempPathTest.php b/tests/Media/MediaServiceInvalidTempPathTest.php index e4129dd..af1c7b1 100644 --- a/tests/Media/MediaServiceInvalidTempPathTest.php +++ b/tests/Media/MediaServiceInvalidTempPathTest.php @@ -6,6 +6,7 @@ namespace Tests\Media; use App\Media\Exception\StorageException; use App\Media\MediaRepositoryInterface; use App\Media\MediaService; +use App\Post\PostRepositoryInterface; use PHPUnit\Framework\TestCase; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UploadedFileInterface; @@ -25,7 +26,9 @@ final class MediaServiceInvalidTempPathTest extends TestCase $file->method('getSize')->willReturn(128); $file->method('getStream')->willReturn($stream); - $service = new MediaService($repository, sys_get_temp_dir(), '/media', 500000); + $postRepo = $this->createMock(PostRepositoryInterface::class); + + $service = new MediaService($repository, $postRepo, 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 e84b42e..4463f61 100644 --- a/tests/Media/MediaServiceTest.php +++ b/tests/Media/MediaServiceTest.php @@ -8,6 +8,7 @@ use App\Media\Exception\InvalidMimeTypeException; use App\Media\Media; use App\Media\MediaRepositoryInterface; use App\Media\MediaService; +use App\Post\PostRepositoryInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\StreamInterface; @@ -33,6 +34,8 @@ final class MediaServiceTest extends TestCase /** @var MediaRepositoryInterface&MockObject */ private MediaRepositoryInterface $repository; + private PostRepositoryInterface $postRepository; + private string $uploadDir; private MediaService $service; @@ -40,11 +43,13 @@ final class MediaServiceTest extends TestCase protected function setUp(): void { $this->repository = $this->createMock(MediaRepositoryInterface::class); + $this->postRepository = $this->createMock(PostRepositoryInterface::class); $this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid(); @mkdir($this->uploadDir, 0755, true); $this->service = new MediaService( mediaRepository: $this->repository, + postRepository: $this->postRepository, uploadDir: $this->uploadDir, uploadUrl: '/media', maxSize: 5 * 1024 * 1024, @@ -104,10 +109,13 @@ final class MediaServiceTest extends TestCase public function testStoreReturnsDuplicateUrl(): void { $tmpFile = $this->createMinimalJpeg(); - $hash = hash_file('sha256', $tmpFile); - $existing = new Media(7, 'existing.jpg', '/media/existing.jpg', $hash, 1); - $this->repository->method('findByHash')->willReturn($existing); + $existing = new Media(7, 'existing.jpg', '/media/existing.jpg', 'existing-hash', 1); + $this->repository + ->expects($this->once()) + ->method('findByHashForUser') + ->with($this->callback(static fn (mixed $value): bool => is_string($value) && $value !== ''), 1) + ->willReturn($existing); $this->repository->expects($this->never())->method('create'); $file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile)); @@ -128,7 +136,7 @@ final class MediaServiceTest extends TestCase { $tmpFile = $this->createMinimalJpeg(); - $this->repository->method('findByHash')->willReturn(null); + $this->repository->method('findByHashForUser')->willReturn(null); $this->repository->expects($this->once())->method('create'); $file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile)); diff --git a/tests/Post/PostConcurrentUpdateIntegrationTest.php b/tests/Post/PostConcurrentUpdateIntegrationTest.php index cc02775..51c5818 100644 --- a/tests/Post/PostConcurrentUpdateIntegrationTest.php +++ b/tests/Post/PostConcurrentUpdateIntegrationTest.php @@ -37,10 +37,16 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase $realRepo = new PostRepository($this->db); $repo = new class($realRepo) implements PostRepositoryInterface { private bool $deleted = false; + public function __construct(private readonly PostRepository $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); } + public function countAll(?int $categoryId = null): int { return $this->inner->countAll($categoryId); } public function findRecent(int $limit): array { return $this->inner->findRecent($limit); } public function findByUserId(int $userId, ?int $categoryId = null): array { return $this->inner->findByUserId($userId, $categoryId); } + public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array { return $this->inner->findByUserPage($userId, $limit, $offset, $categoryId); } + public function countByUserId(int $userId, ?int $categoryId = null): int { return $this->inner->countByUserId($userId, $categoryId); } public function findBySlug(string $slug): ?Post { return $this->inner->findBySlug($slug); } public function findById(int $id): ?Post { return $this->inner->findById($id); } public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int { return $this->inner->create($post, $slug, $authorId, $categoryId); } @@ -53,7 +59,11 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase } public function delete(int $id): int { return $this->inner->delete($id); } public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array { return $this->inner->search($query, $categoryId, $authorId); } + public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array { return $this->inner->searchPage($query, $limit, $offset, $categoryId, $authorId); } + public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int { return $this->inner->countSearch($query, $categoryId, $authorId); } public function slugExists(string $slug, ?int $excludeId = null): bool { return $this->inner->slugExists($slug, $excludeId); } + public function countByEmbeddedMediaUrl(string $url): int { return $this->inner->countByEmbeddedMediaUrl($url); } + public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array { return $this->inner->findByEmbeddedMediaUrl($url, $limit); } }; $sanitizer = new class implements HtmlSanitizerInterface { diff --git a/tests/Post/PostControllerTest.php b/tests/Post/PostControllerTest.php index 67d8095..aef70b1 100644 --- a/tests/Post/PostControllerTest.php +++ b/tests/Post/PostControllerTest.php @@ -11,9 +11,10 @@ use App\Post\PostServiceInterface; use App\Shared\Exception\NotFoundException; use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; +use App\Shared\Pagination\PaginatedResult; use PHPUnit\Framework\MockObject\MockObject; use Slim\Exception\HttpNotFoundException; -use Tests\ControllerTestCase; +use Tests\ControllerTestBase; /** * Tests unitaires pour PostController. @@ -28,7 +29,7 @@ use Tests\ControllerTestCase; * - delete() : 404, droits insuffisants, succès */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -final class PostControllerTest extends ControllerTestCase +final class PostControllerTest extends ControllerTestBase { /** @var \Slim\Views\Twig&MockObject */ private \Slim\Views\Twig $view; @@ -73,8 +74,8 @@ final class PostControllerTest extends ControllerTestCase */ public function testIndexCallsGetAllPostsWithNoFilter(): void { - $this->postService->expects($this->once())->method('getAllPosts')->with(null)->willReturn([]); - $this->postService->expects($this->never())->method('searchPosts'); + $this->postService->expects($this->once())->method('getAllPostsPaginated')->with(1, 6, null)->willReturn(new PaginatedResult([], 0, 1, 6)); + $this->postService->expects($this->never())->method('searchPostsPaginated'); $res = $this->controller->index($this->makeGet('/'), $this->makeResponse()); @@ -87,10 +88,10 @@ final class PostControllerTest extends ControllerTestCase public function testIndexCallsSearchPostsWhenQueryParamPresent(): void { $this->postService->expects($this->once()) - ->method('searchPosts') - ->with('php', null) - ->willReturn([]); - $this->postService->expects($this->never())->method('getAllPosts'); + ->method('searchPostsPaginated') + ->with('php', 1, 6, null) + ->willReturn(new PaginatedResult([], 0, 1, 6)); + $this->postService->expects($this->never())->method('getAllPostsPaginated'); $this->controller->index($this->makeGet('/', ['q' => 'php']), $this->makeResponse()); } @@ -104,9 +105,9 @@ final class PostControllerTest extends ControllerTestCase $this->categoryService->expects($this->once())->method('findBySlug')->with('php')->willReturn($category); $this->postService->expects($this->once()) - ->method('getAllPosts') - ->with(3) - ->willReturn([]); + ->method('getAllPostsPaginated') + ->with(1, 6, 3) + ->willReturn(new PaginatedResult([], 0, 1, 6)); $this->controller->index( $this->makeGet('/', ['categorie' => 'php']), @@ -165,8 +166,8 @@ final class PostControllerTest extends ControllerTestCase $this->sessionManager->method('isAdmin')->willReturn(true); $this->sessionManager->method('isEditor')->willReturn(false); - $this->postService->expects($this->once())->method('getAllPosts')->willReturn([]); - $this->postService->expects($this->never())->method('getPostsByUserId'); + $this->postService->expects($this->once())->method('getAllPostsPaginated')->with(1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12)); + $this->postService->expects($this->never())->method('getPostsByUserIdPaginated'); $res = $this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse()); @@ -182,8 +183,8 @@ final class PostControllerTest extends ControllerTestCase $this->sessionManager->method('isEditor')->willReturn(false); $this->sessionManager->method('getUserId')->willReturn(5); - $this->postService->expects($this->once())->method('getPostsByUserId')->with(5, null)->willReturn([]); - $this->postService->expects($this->never())->method('getAllPosts'); + $this->postService->expects($this->once())->method('getPostsByUserIdPaginated')->with(5, 1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12)); + $this->postService->expects($this->never())->method('getAllPostsPaginated'); $this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse()); } @@ -197,9 +198,9 @@ final class PostControllerTest extends ControllerTestCase $this->sessionManager->method('isEditor')->willReturn(false); $this->postService->expects($this->once()) - ->method('searchPosts') - ->with('php', null, null) - ->willReturn([]); + ->method('searchPostsPaginated') + ->with('php', 1, 12, null, null) + ->willReturn(new PaginatedResult([], 0, 1, 12)); $this->controller->admin( $this->makeGet('/admin/posts', ['q' => 'php']), @@ -492,7 +493,7 @@ final class PostControllerTest extends ControllerTestCase * Crée une entité Post de test avec les paramètres minimaux. * * Nommé buildPostEntity (et non makePost) pour ne pas masquer - * ControllerTestCase::makePost() qui forge une requête HTTP. + * ControllerTestBase::makePost() qui forge une requête HTTP. */ private function buildPostEntity( int $id, diff --git a/tests/Post/PostRepositoryTest.php b/tests/Post/PostRepositoryTest.php index c50abb3..feb4c7c 100644 --- a/tests/Post/PostRepositoryTest.php +++ b/tests/Post/PostRepositoryTest.php @@ -143,9 +143,7 @@ final class PostRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with($this->callback(fn (array $p): bool => - isset($p[':category_id']) && $p[':category_id'] === 3 - )); + ->with([':category_id' => 3]); $this->repository->findAll(3); } @@ -217,9 +215,7 @@ final class PostRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with($this->callback(fn (array $p): bool => - isset($p[':author_id']) && $p[':author_id'] === 7 - )); + ->with([':author_id' => 7]); $this->repository->findByUserId(7); } @@ -234,11 +230,7 @@ final class PostRepositoryTest extends TestCase $stmt->expects($this->once()) ->method('execute') - ->with($this->callback(fn (array $p): bool => - isset($p[':author_id'], $p[':category_id']) - && $p[':author_id'] === 7 - && $p[':category_id'] === 3 - )); + ->with([':author_id' => 7, ':category_id' => 3]); $this->repository->findByUserId(7, 3); } diff --git a/tests/Post/RssControllerTest.php b/tests/Post/RssControllerTest.php index f920ad4..2c2727b 100644 --- a/tests/Post/RssControllerTest.php +++ b/tests/Post/RssControllerTest.php @@ -7,7 +7,7 @@ use App\Post\Post; use App\Post\PostServiceInterface; use App\Post\RssController; use PHPUnit\Framework\MockObject\MockObject; -use Tests\ControllerTestCase; +use Tests\ControllerTestBase; /** * Tests unitaires pour RssController. @@ -20,7 +20,7 @@ use Tests\ControllerTestCase; * - Appel à getRecentPosts() avec la constante FEED_LIMIT (20) */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -final class RssControllerTest extends ControllerTestCase +final class RssControllerTest extends ControllerTestBase { /** @var PostServiceInterface&MockObject */ private PostServiceInterface $postService; diff --git a/tests/User/UserControllerTest.php b/tests/User/UserControllerTest.php index 1546890..1253d05 100644 --- a/tests/User/UserControllerTest.php +++ b/tests/User/UserControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\User; use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\SessionManagerInterface; +use App\Shared\Pagination\PaginatedResult; use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateUsernameException; use App\User\Exception\WeakPasswordException; @@ -12,7 +13,7 @@ use App\User\User; use App\User\UserController; use App\User\UserServiceInterface; use PHPUnit\Framework\MockObject\MockObject; -use Tests\ControllerTestCase; +use Tests\ControllerTestBase; /** * Tests unitaires pour UserController. @@ -25,7 +26,7 @@ use Tests\ControllerTestCase; * - delete() : introuvable, cible admin, soi-même, succès */ #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] -final class UserControllerTest extends ControllerTestCase +final class UserControllerTest extends ControllerTestBase { /** @var \Slim\Views\Twig&MockObject */ private \Slim\Views\Twig $view; @@ -63,7 +64,7 @@ final class UserControllerTest extends ControllerTestCase */ public function testIndexRendersWithUserList(): void { - $this->userService->method('findAll')->willReturn([]); + $this->userService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20)); $this->sessionManager->method('getUserId')->willReturn(1); $this->view->expects($this->once()) diff --git a/tests/User/UserServiceTest.php b/tests/User/UserServiceTest.php index d3fc361..4525839 100644 --- a/tests/User/UserServiceTest.php +++ b/tests/User/UserServiceTest.php @@ -235,7 +235,7 @@ final class UserServiceTest extends TestCase } /** - * updateRole() accepte les trois rôles valides sans lever d'exception. + * updateRole() accepte uniquement les rôles attribuables depuis l'interface. */ #[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')] public function testUpdateRoleAcceptsAllValidRoles(string $role): void @@ -254,7 +254,6 @@ final class UserServiceTest extends TestCase return [ 'user' => [User::ROLE_USER], 'editor' => [User::ROLE_EDITOR], - 'admin' => [User::ROLE_ADMIN], ]; } diff --git a/views/admin/categories/index.twig b/views/admin/categories/index.twig index d49e18a..13b326c 100644 --- a/views/admin/categories/index.twig +++ b/views/admin/categories/index.twig @@ -54,9 +54,7 @@ @@ -66,6 +64,8 @@ Cette action est impossible si des articles lui sont rattachés.')"> {% endfor %} + +{% include 'partials/_pagination.twig' with { pagination: pagination } %} {% else %}

Aucune catégorie créée.

{% endif %} diff --git a/views/admin/media/index.twig b/views/admin/media/index.twig index 006193f..a1b5ffc 100644 --- a/views/admin/media/index.twig +++ b/views/admin/media/index.twig @@ -21,12 +21,14 @@ Aperçu URL + Usage Uploadé le Actions {% for item in media %} + {% set usage = mediaUsage[item.id] ?? {'count': 0, 'posts': []} %}
@@ -47,6 +49,18 @@
+ + {% if usage.count > 0 %} + {{ usage.count }} article{{ usage.count > 1 ? 's' : '' }} + + {% else %} + Aucun + {% endif %} + {{ item.createdAt|date("d/m/Y H:i") }}
@@ -54,7 +68,8 @@ @@ -64,6 +79,8 @@ {% endfor %} + +{% include 'partials/_pagination.twig' with { pagination: pagination } %} {% else %}

Aucun fichier uploadé.

{% endif %} diff --git a/views/admin/posts/index.twig b/views/admin/posts/index.twig index 7de1574..9da2dc6 100644 --- a/views/admin/posts/index.twig +++ b/views/admin/posts/index.twig @@ -25,8 +25,8 @@ {% if searchQuery %}

- {% if posts is not empty %} - {{ posts|length }} résultat{{ posts|length > 1 ? 's' : '' }} pour « {{ searchQuery }} » + {% if totalPosts > 0 %} + {{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} » {% else %} Aucun résultat pour « {{ searchQuery }} » {% endif %} @@ -74,8 +74,7 @@ {{ post.title }} {% if post.categoryName %} - {{ post.categoryName }} + {{ post.categoryName }} {% else %} {% endif %} @@ -101,6 +100,8 @@ {% endfor %} + +{% include 'partials/_pagination.twig' with { pagination: pagination } %} {% else %}

{% if searchQuery %}Aucun résultat pour « {{ searchQuery }} ».{% else %}Aucun article à gérer.{% endif %}

{% endif %} diff --git a/views/admin/users/form.twig b/views/admin/users/form.twig index 5e2f59f..d635272 100644 --- a/views/admin/users/form.twig +++ b/views/admin/users/form.twig @@ -54,10 +54,12 @@ + Le rôle administrateur reste réservé au provisionnement initial.

diff --git a/views/admin/users/index.twig b/views/admin/users/index.twig index 11841b9..1868635 100644 --- a/views/admin/users/index.twig +++ b/views/admin/users/index.twig @@ -58,9 +58,11 @@
@@ -89,6 +91,8 @@ {% endfor %} + +{% include 'partials/_pagination.twig' with { pagination: pagination } %} {% else %}

Aucun utilisateur.

{% endif %} diff --git a/views/pages/home.twig b/views/pages/home.twig index 3dd660f..501dcf0 100644 --- a/views/pages/home.twig +++ b/views/pages/home.twig @@ -26,8 +26,8 @@ {% if searchQuery %}

- {% if posts is not empty %} - {{ posts|length }} résultat{{ posts|length > 1 ? 's' : '' }} pour « {{ searchQuery }} » + {% if totalPosts > 0 %} + {{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} » {% else %} Aucun résultat pour « {{ searchQuery }} » {% endif %} @@ -53,7 +53,6 @@ {% for post in posts %} {% set thumb = post_thumbnail(post) %}

- {% else %}

Aucun article publié{% if searchQuery %} pour « {{ searchQuery }} »{% elseif activeCategory %} dans cette catégorie{% endif %}.

{% endfor %} + +{% include 'partials/_pagination.twig' with { pagination: pagination } %} {% endblock %} diff --git a/views/partials/_pagination.twig b/views/partials/_pagination.twig new file mode 100644 index 0000000..56f530f --- /dev/null +++ b/views/partials/_pagination.twig @@ -0,0 +1,29 @@ +{% if pagination.totalPages > 1 %} + +{% endif %}