Working state

This commit is contained in:
julien
2026-03-16 13:40:18 +01:00
parent dec76fa2c7
commit 557360dfde
57 changed files with 1044 additions and 1668 deletions

View File

@@ -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) # Taille maximale en octets (doit être < upload_max_filesize dans docker/php/php.ini en production)
UPLOAD_MAX_SIZE=5242880 UPLOAD_MAX_SIZE=5242880
SESSION_NAME=slim_blog_session

1
.gitignore vendored
View File

@@ -24,7 +24,6 @@ public/assets/
database/*.sqlite database/*.sqlite
database/*.sqlite-shm database/*.sqlite-shm
database/*.sqlite-wal database/*.sqlite-wal
database/.provision.lock
# ============================================ # ============================================
# Cache & Logs # Cache & Logs

View File

@@ -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 - 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. 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`.

View File

@@ -144,9 +144,10 @@ return [
}), }),
MediaServiceInterface::class => factory( MediaServiceInterface::class => factory(
function (MediaRepositoryInterface $mediaRepository): MediaServiceInterface { function (MediaRepositoryInterface $mediaRepository, PostRepositoryInterface $postRepository): MediaServiceInterface {
return new MediaService( return new MediaService(
mediaRepository: $mediaRepository, mediaRepository: $mediaRepository,
postRepository: $postRepository,
uploadDir: dirname(__DIR__) . '/public/media', uploadDir: dirname(__DIR__) . '/public/media',
uploadUrl: '/media', uploadUrl: '/media',
maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024), maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024),

View File

@@ -1,28 +1,21 @@
<?php <?php
/**
* Migration 004 — Création de la table des médias uploadés.
*
* La colonne hash (SHA-256) est unique et permet la détection des doublons à l'upload.
* user_id est nullable : SET NULL si le compte auteur est supprimé.
*
* Index explicite sur user_id : SQLite n'indexe pas automatiquement les FK.
* Utilisé par findByUserId() dans la galerie média.
*/
return [ return [
'up' => " 'up' => "
CREATE TABLE IF NOT EXISTS media ( CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL, filename TEXT NOT NULL,
url 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, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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_user_id ON media(user_id);
CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id);
", ",
'down' => " 'down' => "
DROP INDEX IF EXISTS idx_media_hash_user_id;
DROP INDEX IF EXISTS idx_media_user_id; DROP INDEX IF EXISTS idx_media_user_id;
DROP TABLE IF EXISTS media; DROP TABLE IF EXISTS media;
", ",

View File

@@ -4,6 +4,8 @@
bootstrap="vendor/autoload.php" bootstrap="vendor/autoload.php"
colors="true" colors="true"
displayDetailsOnTestsThatTriggerDeprecations="true" displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnPhpunitDeprecations="true"
displayDetailsOnPhpunitNotices="true"
displayDetailsOnTestsThatTriggerErrors="true" displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true" displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"> displayDetailsOnTestsThatTriggerWarnings="true">

View File

@@ -10,11 +10,20 @@ $bootstrap = Bootstrap::create();
$bootstrap->initializeInfrastructure(); $bootstrap->initializeInfrastructure();
$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER); $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([ session_start([
'cookie_secure' => RequestContext::isHttps($_SERVER, $trustedProxies), 'cookie_secure' => RequestContext::isHttps($_SERVER, $trustedProxies),
'cookie_httponly' => true, 'cookie_httponly' => true,
'cookie_samesite' => 'Lax', 'cookie_samesite' => 'Lax',
'cookie_lifetime' => 0,
'use_strict_mode' => 1,
]); ]);
$app = $bootstrap->createHttpApp(); $app = $bootstrap->createHttpApp();

View File

@@ -4,25 +4,15 @@ declare(strict_types=1);
namespace App\Category; namespace App\Category;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\Shared\Pagination\PaginationPresenter;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig; use Slim\Views\Twig;
/**
* Contrôleur 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 final class CategoryController
{ {
/** private const PER_PAGE = 20;
* @param Twig $view Moteur de templates Twig
* @param CategoryServiceInterface $categoryService Service de gestion des catégories
* @param FlashServiceInterface $flash Service de messages flash
*/
public function __construct( public function __construct(
private readonly Twig $view, private readonly Twig $view,
private readonly CategoryServiceInterface $categoryService, 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 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', [ 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'), 'error' => $this->flash->get('category_error'),
'success' => $this->flash->get('category_success'), '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 public function create(Request $req, Response $res): Response
{ {
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody(); $data = (array) $req->getParsedBody();
$name = (string) ($data['name'] ?? ''); $name = (string) ($data['name'] ?? '');
@@ -78,16 +52,7 @@ final class CategoryController
} }
/** /**
* Supprime une catégorie. * @param array<string, mixed> $args
*
* 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<string, string> $args Paramètres de route (id)
*
* @return Response Une redirection vers /admin/categories
*/ */
public function delete(Request $req, Response $res, array $args): Response public function delete(Request $req, Response $res, array $args): Response
{ {
@@ -96,7 +61,6 @@ final class CategoryController
if ($category === null) { if ($category === null) {
$this->flash->set('category_error', 'Catégorie introuvable'); $this->flash->set('category_error', 'Catégorie introuvable');
return $res->withHeader('Location', '/admin/categories')->withStatus(302); return $res->withHeader('Location', '/admin/categories')->withStatus(302);
} }

View File

@@ -5,78 +5,60 @@ namespace App\Category;
use PDO; 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 final class CategoryRepository implements CategoryRepositoryInterface
{ {
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db) 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 public function findAll(): array
{ {
$stmt = $this->db->query('SELECT * FROM categories ORDER BY name ASC'); $stmt = $this->db->query('SELECT * FROM categories ORDER BY name ASC');
if ($stmt === false) { if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur categories a échoué.'); 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 public function findById(int $id): ?Category
{ {
$stmt = $this->db->prepare('SELECT * FROM categories WHERE id = :id'); $stmt = $this->db->prepare('SELECT * FROM categories WHERE id = :id');
$stmt->execute([':id' => $id]); $stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Category::fromArray($row) : null; 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 public function findBySlug(string $slug): ?Category
{ {
$stmt = $this->db->prepare('SELECT * FROM categories WHERE slug = :slug'); $stmt = $this->db->prepare('SELECT * FROM categories WHERE slug = :slug');
$stmt->execute([':slug' => $slug]); $stmt->execute([':slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Category::fromArray($row) : null; 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 public function create(Category $category): int
{ {
$stmt = $this->db->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)'); $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(); 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 public function delete(int $id): int
{ {
$stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id'); $stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id');
@@ -100,13 +75,6 @@ final class CategoryRepository implements CategoryRepositoryInterface
return $stmt->rowCount(); 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 public function nameExists(string $name): bool
{ {
$stmt = $this->db->prepare('SELECT 1 FROM categories WHERE name = :name'); $stmt = $this->db->prepare('SELECT 1 FROM categories WHERE name = :name');
@@ -115,15 +83,6 @@ final class CategoryRepository implements CategoryRepositoryInterface
return $stmt->fetchColumn() !== false; 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 public function hasPost(int $id): bool
{ {
$stmt = $this->db->prepare('SELECT 1 FROM posts WHERE category_id = :id'); $stmt = $this->db->prepare('SELECT 1 FROM posts WHERE category_id = :id');

View File

@@ -3,72 +3,25 @@ declare(strict_types=1);
namespace App\Category; 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 interface CategoryRepositoryInterface
{ {
/** /** @return Category[] */
* Retourne toutes les catégories triées alphabétiquement.
*
* @return Category[]
*/
public function findAll(): array; public function findAll(): array;
/** /** @return Category[] */
* Trouve une catégorie par son identifiant. public function findPage(int $limit, int $offset): array;
*
* @param int $id Identifiant de la catégorie public function countAll(): int;
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findById(int $id): ?Category; 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; 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; 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; 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; 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; public function hasPost(int $id): bool;
} }

View File

@@ -3,82 +3,48 @@ declare(strict_types=1);
namespace App\Category; namespace App\Category;
use App\Shared\Pagination\PaginatedResult;
use App\Shared\Util\SlugHelper; 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 final class CategoryService implements CategoryServiceInterface
{ {
/**
* @param CategoryRepositoryInterface $categoryRepository Dépôt de persistance des catégories
*/
public function __construct( public function __construct(
private readonly CategoryRepositoryInterface $categoryRepository, private readonly CategoryRepositoryInterface $categoryRepository,
) { ) {
} }
/**
* Retourne toutes les catégories triées alphabétiquement.
*
* @return Category[]
*/
public function findAll(): array public function findAll(): array
{ {
return $this->categoryRepository->findAll(); return $this->categoryRepository->findAll();
} }
/** /**
* Trouve une catégorie par son identifiant. * @return PaginatedResult<Category>
*
* @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 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 public function findById(int $id): ?Category
{ {
return $this->categoryRepository->findById($id); 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 public function findBySlug(string $slug): ?Category
{ {
return $this->categoryRepository->findBySlug($slug); 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 public function create(string $name): int
{ {
$name = trim($name); $name = trim($name);
@@ -92,21 +58,9 @@ final class CategoryService implements CategoryServiceInterface
throw new \InvalidArgumentException('Ce nom de catégorie est déjà utilisé'); 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)); 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 public function delete(Category $category): void
{ {
if ($this->categoryRepository->hasPost($category->getId())) { if ($this->categoryRepository->hasPost($category->getId())) {

View File

@@ -3,62 +3,23 @@ declare(strict_types=1);
namespace App\Category; namespace App\Category;
/** use App\Shared\Pagination\PaginatedResult;
* 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.
*/
interface CategoryServiceInterface interface CategoryServiceInterface
{ {
/** /** @return Category[] */
* Retourne toutes les catégories triées alphabétiquement.
*
* @return Category[]
*/
public function findAll(): array; public function findAll(): array;
/** /**
* Trouve une catégorie par son identifiant. * @return PaginatedResult<Category>
*
* @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 findPaginated(int $page, int $perPage): PaginatedResult;
public function findById(int $id): ?Category; 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; 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; 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; public function delete(Category $category): void;
} }

View File

@@ -6,37 +6,17 @@ namespace App\Media;
use App\Media\Exception\FileTooLargeException; use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException; use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Exception\StorageException; use App\Media\Exception\StorageException;
use App\Media\MediaServiceInterface;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface; use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginationPresenter;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig; use Slim\Views\Twig;
/**
* Contrôleur 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 final class MediaController
{ {
/** private const PER_PAGE = 12;
* @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
*/
public function __construct( public function __construct(
private readonly Twig $view, private readonly Twig $view,
private readonly MediaServiceInterface $mediaService, 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 public function index(Request $req, Response $res): Response
{ {
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
$userId = $this->sessionManager->getUserId(); $userId = $this->sessionManager->getUserId();
$page = PaginationPresenter::resolvePage($req->getQueryParams());
$media = $isAdmin $paginated = $isAdmin
? $this->mediaService->findAll() ? $this->mediaService->findPaginated($page, self::PER_PAGE)
: $this->mediaService->findByUserId((int) $userId); : $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', [ return $this->view->render($res, 'admin/media/index.twig', [
'media' => $media, 'media' => $paginated->getItems(),
'mediaUsage' => $usageByMediaId,
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
'error' => $this->flash->get('media_error'), 'error' => $this->flash->get('media_error'),
'success' => $this->flash->get('media_success'), '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 public function upload(Request $req, Response $res): Response
{ {
$files = $req->getUploadedFiles(); $files = $req->getUploadedFiles();
@@ -107,16 +72,7 @@ final class MediaController
} }
/** /**
* Supprime un média (fichier sur disque + entrée en base). * @param array<string, mixed> $args
*
* 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<string, string> $args Paramètres de route (id)
*
* @return Response Redirection vers /admin/media
*/ */
public function delete(Request $req, Response $res, array $args): Response public function delete(Request $req, Response $res, array $args): Response
{ {
@@ -125,7 +81,6 @@ final class MediaController
if ($media === null) { if ($media === null) {
$this->flash->set('media_error', 'Fichier introuvable'); $this->flash->set('media_error', 'Fichier introuvable');
return $res->withHeader('Location', '/admin/media')->withStatus(302); return $res->withHeader('Location', '/admin/media')->withStatus(302);
} }
@@ -134,6 +89,25 @@ final class MediaController
if (!$isAdmin && $media->getUserId() !== $userId) { if (!$isAdmin && $media->getUserId() !== $userId) {
$this->flash->set('media_error', "Vous n'êtes pas autorisé à supprimer ce fichier"); $this->flash->set('media_error', "Vous n'êtes pas autorisé à supprimer ce fichier");
return $res->withHeader('Location', '/admin/media')->withStatus(302);
}
/** @var array<string, mixed> $usage */
$usage = $this->mediaService->getUsageSummary($media, 3);
$usageCount = isset($usage['count']) && is_int($usage['count']) ? $usage['count'] : 0;
/** @var array<int, \App\Post\Post> $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); return $res->withHeader('Location', '/admin/media')->withStatus(302);
} }
@@ -144,14 +118,6 @@ final class MediaController
return $res->withHeader('Location', '/admin/media')->withStatus(302); 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 private function jsonSuccess(Response $res, string $fileUrl): Response
{ {
$res->getBody()->write(json_encode([ $res->getBody()->write(json_encode([
@@ -162,15 +128,6 @@ final class MediaController
return $res->withHeader('Content-Type', 'application/json')->withStatus(200); 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 private function jsonError(Response $res, string $message, int $status): Response
{ {
$res->getBody()->write(json_encode(['error' => $message], JSON_THROW_ON_ERROR)); $res->getBody()->write(json_encode(['error' => $message], JSON_THROW_ON_ERROR));

View File

@@ -5,109 +5,104 @@ namespace App\Media;
use PDO; 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 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'; 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) 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 public function findAll(): array
{ {
$stmt = $this->db->query(self::SELECT . ' ORDER BY id DESC'); $stmt = $this->db->query(self::SELECT . ' ORDER BY id DESC');
if ($stmt === false) { if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur media a échoué.'); 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 public function findByUserId(int $userId): array
{ {
$stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC'); $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC');
$stmt->execute([':user_id' => $userId]); $stmt->execute([':user_id' => $userId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
return array_map(fn ($row) => Media::fromArray($row), $rows);
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 public function findById(int $id): ?Media
{ {
$stmt = $this->db->prepare(self::SELECT . ' WHERE id = :id'); $stmt = $this->db->prepare(self::SELECT . ' WHERE id = :id');
$stmt->execute([':id' => $id]); $stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Media::fromArray($row) : null; 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 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]); $stmt->execute([':hash' => $hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Media::fromArray($row) : null; return $row ? Media::fromArray($row) : null;
} }
/** public function findByHashForUser(string $hash, int $userId): ?Media
* Persiste un nouveau média en base de données. {
* $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash AND user_id = :user_id ORDER BY id DESC LIMIT 1');
* @param Media $media Le média à créer $stmt->execute([':hash' => $hash, ':user_id' => $userId]);
* $row = $stmt->fetch(PDO::FETCH_ASSOC);
* @return int L'identifiant généré par la base de données
*/ return $row ? Media::fromArray($row) : null;
}
public function create(Media $media): int public function create(Media $media): int
{ {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare(
INSERT INTO media (filename, url, hash, user_id, created_at) 'INSERT INTO media (filename, url, hash, user_id, created_at)
VALUES (:filename, :url, :hash, :user_id, :created_at) VALUES (:filename, :url, :hash, :user_id, :created_at)'
'); );
$stmt->execute([ $stmt->execute([
':filename' => $media->getFilename(), ':filename' => $media->getFilename(),
@@ -120,15 +115,6 @@ final class MediaRepository implements MediaRepositoryInterface
return (int) $this->db->lastInsertId(); 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 public function delete(int $id): int
{ {
$stmt = $this->db->prepare('DELETE FROM media WHERE id = :id'); $stmt = $this->db->prepare('DELETE FROM media WHERE id = :id');

View File

@@ -3,63 +3,31 @@ declare(strict_types=1);
namespace App\Media; 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 interface MediaRepositoryInterface
{ {
/** /** @return Media[] */
* Retourne tous les médias triés du plus récent au plus ancien.
*
* @return Media[]
*/
public function findAll(): array; public function findAll(): array;
/** /** @return Media[] */
* Retourne tous les médias d'un utilisateur donné. public function findPage(int $limit, int $offset): array;
*
* @param int $userId Identifiant de l'utilisateur public function countAll(): int;
*
* @return Media[] /** @return Media[] */
*/
public function findByUserId(int $userId): array; public function findByUserId(int $userId): array;
/** /** @return Media[] */
* Trouve un média par son identifiant. public function findByUserPage(int $userId, int $limit, int $offset): array;
*
* @param int $id Identifiant du média public function countByUserId(int $userId): int;
*
* @return Media|null Le média trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Media; 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; public function findByHash(string $hash): ?Media;
/** public function findByHashForUser(string $hash, int $userId): ?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 create(Media $media): int; 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; public function delete(int $id): int;
} }

View File

@@ -6,38 +6,32 @@ namespace App\Media;
use App\Media\Exception\FileTooLargeException; use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException; use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Exception\StorageException; use App\Media\Exception\StorageException;
use App\Post\PostRepositoryInterface;
use App\Shared\Pagination\PaginatedResult;
use PDOException; use PDOException;
use Psr\Http\Message\UploadedFileInterface; use Psr\Http\Message\UploadedFileInterface;
final class MediaService implements MediaServiceInterface final class MediaService implements MediaServiceInterface
{ {
private const ALLOWED_MIME_TYPES = [ private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
private const WEBP_CONVERTIBLE = ['image/jpeg', 'image/png']; private const WEBP_CONVERTIBLE = ['image/jpeg', 'image/png'];
private const MIME_EXTENSIONS = [ private const MIME_EXTENSIONS = [
'image/jpeg' => 'webp', 'image/jpeg' => 'webp',
'image/png' => 'webp', 'image/png' => 'webp',
'image/gif' => 'gif', 'image/gif' => 'gif',
'image/webp' => 'webp', 'image/webp' => 'webp',
]; ];
private const MIME_EXTENSIONS_FALLBACK = [ private const MIME_EXTENSIONS_FALLBACK = [
'image/jpeg' => 'jpg', 'image/jpeg' => 'jpg',
'image/png' => 'png', 'image/png' => 'png',
'image/gif' => 'gif', 'image/gif' => 'gif',
'image/webp' => 'webp', 'image/webp' => 'webp',
]; ];
private const MAX_PIXELS = 40000000; private const MAX_PIXELS = 40000000;
public function __construct( public function __construct(
private readonly MediaRepositoryInterface $mediaRepository, private readonly MediaRepositoryInterface $mediaRepository,
private readonly PostRepositoryInterface $postRepository,
private readonly string $uploadDir, private readonly string $uploadDir,
private readonly string $uploadUrl, private readonly string $uploadUrl,
private readonly int $maxSize, private readonly int $maxSize,
@@ -49,11 +43,45 @@ final class MediaService implements MediaServiceInterface
return $this->mediaRepository->findAll(); return $this->mediaRepository->findAll();
} }
/**
* @return PaginatedResult<Media>
*/
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 public function findByUserId(int $userId): array
{ {
return $this->mediaRepository->findByUserId($userId); return $this->mediaRepository->findByUserId($userId);
} }
/**
* @return PaginatedResult<Media>
*/
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 public function findById(int $id): ?Media
{ {
return $this->mediaRepository->findById($id); return $this->mediaRepository->findById($id);
@@ -106,7 +134,7 @@ final class MediaService implements MediaServiceInterface
} }
$hash = $rawHash; $hash = $rawHash;
$existing = $this->mediaRepository->findByHash($hash); $existing = $this->mediaRepository->findByHashForUser($hash, $userId);
if ($existing !== null) { if ($existing !== null) {
if ($converted) { if ($converted) {
@@ -119,9 +147,7 @@ final class MediaService implements MediaServiceInterface
throw new StorageException("Impossible de créer le répertoire d'upload"); throw new StorageException("Impossible de créer le répertoire d'upload");
} }
$extension = $converted $extension = $converted ? self::MIME_EXTENSIONS[$mime] : self::MIME_EXTENSIONS_FALLBACK[$mime];
? self::MIME_EXTENSIONS[$mime]
: self::MIME_EXTENSIONS_FALLBACK[$mime];
$filename = uniqid('', true) . '_' . bin2hex(random_bytes(4)) . '.' . $extension; $filename = uniqid('', true) . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
$destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; $destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
@@ -141,19 +167,27 @@ final class MediaService implements MediaServiceInterface
try { try {
$this->mediaRepository->create($media); $this->mediaRepository->create($media);
} catch (PDOException $e) { } catch (PDOException $e) {
$duplicate = $this->mediaRepository->findByHash($hash);
if ($duplicate !== null) {
@unlink($destPath); @unlink($destPath);
$duplicate = $this->mediaRepository->findByHashForUser($hash, $userId);
if ($duplicate !== null) {
return $duplicate->getUrl(); return $duplicate->getUrl();
} }
@unlink($destPath);
throw $e; throw $e;
} }
return $url; 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 public function delete(Media $media): void
{ {
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $media->getFilename(); $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $media->getFilename();

View File

@@ -3,60 +3,33 @@ declare(strict_types=1);
namespace App\Media; namespace App\Media;
use App\Shared\Pagination\PaginatedResult;
use Psr\Http\Message\UploadedFileInterface; 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 interface MediaServiceInterface
{ {
/** /** @return Media[] */
* Retourne tous les médias triés du plus récent au plus ancien.
*
* @return Media[]
*/
public function findAll(): array; public function findAll(): array;
/** /**
* Retourne tous les médias appartenant à un utilisateur donné. * @return PaginatedResult<Media>
*
* @param int $userId Identifiant de l'utilisateur
*
* @return Media[]
*/ */
public function findPaginated(int $page, int $perPage): PaginatedResult;
/** @return Media[] */
public function findByUserId(int $userId): array; public function findByUserId(int $userId): array;
/** /**
* Trouve un média par son identifiant. * @return PaginatedResult<Media>
*
* @param int $id Identifiant du média
*
* @return Media|null Le média trouvé, ou null s'il n'existe pas
*/ */
public function findByUserIdPaginated(int $userId, int $page, int $perPage): PaginatedResult;
public function findById(int $id): ?Media; 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; public function store(UploadedFileInterface $uploadedFile, int $userId): string;
/** /** @return array{count:int, posts:array<int, \App\Post\Post>} */
* Supprime un média : fichier physique sur disque et entrée en base. public function getUsageSummary(Media $media, int $sampleLimit = 5): array;
*
* @param Media $media Le média à supprimer
* @return void
*/
public function delete(Media $media): void; public function delete(Media $media): void;
} }

View File

@@ -7,32 +7,17 @@ use App\Category\CategoryServiceInterface;
use App\Shared\Exception\NotFoundException; use App\Shared\Exception\NotFoundException;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface; use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginationPresenter;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Exception\HttpNotFoundException; use Slim\Exception\HttpNotFoundException;
use Slim\Views\Twig; 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 final class PostController
{ {
/** private const PUBLIC_PER_PAGE = 6;
* @param Twig $view Moteur de templates Twig private const ADMIN_PER_PAGE = 12;
* @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
*/
public function __construct( public function __construct(
private readonly Twig $view, private readonly Twig $view,
private readonly PostServiceInterface $postService, 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 public function index(Request $req, Response $res): Response
{ {
$params = $req->getQueryParams(); $params = $req->getQueryParams();
$page = PaginationPresenter::resolvePage($params);
$searchQuery = trim((string) ($params['q'] ?? '')); $searchQuery = trim((string) ($params['q'] ?? ''));
$categorySlug = (string) ($params['categorie'] ?? ''); $categorySlug = (string) ($params['categorie'] ?? '');
$activeCategory = null; $activeCategory = null;
@@ -70,12 +41,14 @@ final class PostController
$categoryId = $activeCategory?->getId(); $categoryId = $activeCategory?->getId();
} }
$posts = $searchQuery !== '' $paginated = $searchQuery !== ''
? $this->postService->searchPosts($searchQuery, $categoryId) ? $this->postService->searchPostsPaginated($searchQuery, $page, self::PUBLIC_PER_PAGE, $categoryId)
: $this->postService->getAllPosts($categoryId); : $this->postService->getAllPostsPaginated($page, self::PUBLIC_PER_PAGE, $categoryId);
return $this->view->render($res, 'pages/home.twig', [ 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(), 'categories' => $this->categoryService->findAll(),
'activeCategory' => $activeCategory, 'activeCategory' => $activeCategory,
'searchQuery' => $searchQuery, 'searchQuery' => $searchQuery,
@@ -83,19 +56,7 @@ final class PostController
} }
/** /**
* Affiche le détail d'un article par son slug. * @param array<string, mixed> $args
*
* 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<string, string> $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
*/ */
public function show(Request $req, Response $res, array $args): Response 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]); 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 public function admin(Request $req, Response $res): Response
{ {
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
$userId = $this->sessionManager->getUserId(); $userId = $this->sessionManager->getUserId();
$params = $req->getQueryParams(); $params = $req->getQueryParams();
$page = PaginationPresenter::resolvePage($params);
$searchQuery = trim((string) ($params['q'] ?? '')); $searchQuery = trim((string) ($params['q'] ?? ''));
$categorySlug = (string) ($params['categorie'] ?? ''); $categorySlug = (string) ($params['categorie'] ?? '');
$activeCategory = null; $activeCategory = null;
@@ -144,15 +87,23 @@ final class PostController
if ($searchQuery !== '') { if ($searchQuery !== '') {
$authorId = $isAdmin ? null : (int) $userId; $authorId = $isAdmin ? null : (int) $userId;
$posts = $this->postService->searchPosts($searchQuery, $categoryId, $authorId); $paginated = $this->postService->searchPostsPaginated(
$searchQuery,
$page,
self::ADMIN_PER_PAGE,
$categoryId,
$authorId,
);
} else { } else {
$posts = $isAdmin $paginated = $isAdmin
? $this->postService->getAllPosts($categoryId) ? $this->postService->getAllPostsPaginated($page, self::ADMIN_PER_PAGE, $categoryId)
: $this->postService->getPostsByUserId((int) $userId, $categoryId); : $this->postService->getPostsByUserIdPaginated((int) $userId, $page, self::ADMIN_PER_PAGE, $categoryId);
} }
return $this->view->render($res, 'admin/posts/index.twig', [ 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(), 'categories' => $this->categoryService->findAll(),
'activeCategory' => $activeCategory, 'activeCategory' => $activeCategory,
'searchQuery' => $searchQuery, 'searchQuery' => $searchQuery,
@@ -162,18 +113,7 @@ final class PostController
} }
/** /**
* Affiche le formulaire de création (id=0) ou d'édition d'un article. * @param array<string, mixed> $args
*
* 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<string, string> $args Les paramètres de route (id)
*
* @return Response Le formulaire ou une redirection
*
* @throws HttpNotFoundException Si l'article demandé n'existe pas
*/ */
public function form(Request $req, Response $res, array $args): Response public function form(Request $req, Response $res, array $args): Response
{ {
@@ -187,7 +127,6 @@ final class PostController
throw new HttpNotFoundException($req); throw new HttpNotFoundException($req);
} }
// Vérification des droits avant affichage du formulaire
if (!$this->canEditPost($post)) { if (!$this->canEditPost($post)) {
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur"); $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 public function create(Request $req, Response $res): Response
{ {
['title' => $title, 'content' => $content, 'category_id' => $categoryId] = ['title' => $title, 'content' => $content, 'category_id' => $categoryId] = $this->extractPostData($req);
$this->extractPostData($req);
try { try {
$this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId); $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. * @param array<string, mixed> $args
*
* 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<string, string> $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)
*/ */
public function update(Request $req, Response $res, array $args): Response public function update(Request $req, Response $res, array $args): Response
{ {
$id = (int) $args['id']; $id = (int) $args['id'];
['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] = ['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] = $this->extractPostData($req);
$this->extractPostData($req);
// Récupération de l'article pour vérification des droits avant modification
try { try {
$post = $this->postService->getPostById($id); $post = $this->postService->getPostById($id);
} catch (NotFoundException) { } catch (NotFoundException) {
@@ -276,7 +186,6 @@ final class PostController
$this->postService->updatePost($id, $title, $content, $slug, $categoryId); $this->postService->updatePost($id, $title, $content, $slug, $categoryId);
$this->flash->set('post_success', 'L\'article a été modifié avec succès'); $this->flash->set('post_success', 'L\'article a été modifié avec succès');
} catch (NotFoundException) { } catch (NotFoundException) {
// L'article a disparu entre la vérification des droits et l'UPDATE (race condition)
throw new HttpNotFoundException($req); throw new HttpNotFoundException($req);
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
$this->flash->set('post_error', $e->getMessage()); $this->flash->set('post_error', $e->getMessage());
@@ -292,23 +201,10 @@ final class PostController
} }
/** /**
* Supprime un article. * @param array<string, mixed> $args
*
* 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<string, string> $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)
*/ */
public function delete(Request $req, Response $res, array $args): Response public function delete(Request $req, Response $res, array $args): Response
{ {
// Récupération de l'article pour vérification des droits avant suppression
try { try {
$post = $this->postService->getPostById((int) $args['id']); $post = $this->postService->getPostById((int) $args['id']);
} catch (NotFoundException) { } catch (NotFoundException) {
@@ -324,7 +220,6 @@ final class PostController
try { try {
$this->postService->deletePost($post->getId()); $this->postService->deletePost($post->getId());
} catch (NotFoundException) { } catch (NotFoundException) {
// L'article a disparu entre la vérification des droits et le DELETE (race condition)
throw new HttpNotFoundException($req); throw new HttpNotFoundException($req);
} }
@@ -333,41 +228,22 @@ final class PostController
return $res->withHeader('Location', '/admin/posts')->withStatus(302); 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 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()) { if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) {
return true; return true;
} }
// Un utilisateur standard ne peut agir que sur ses propres articles
return $post->getAuthorId() === $this->sessionManager->getUserId(); return $post->getAuthorId() === $this->sessionManager->getUserId();
} }
/** /**
* Extrait et normalise les données d'article depuis le corps de la requête. * @return array{title: string, content: string, slug: string, category_id: int|null}
*
* @param Request $req La requête HTTP
*
* @return array{title: string, content: string, slug: string, category_id: int|null} Les données nettoyées
*/ */
private function extractPostData(Request $req): array private function extractPostData(Request $req): array
{ {
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody(); $data = (array) $req->getParsedBody();
$categoryId = ($data['category_id'] ?? '') !== '' $categoryId = ($data['category_id'] ?? '') !== '' ? (int) $data['category_id'] : null;
? (int) $data['category_id']
: null;
return [ return [
'title' => trim((string) ($data['title'] ?? '')), 'title' => trim((string) ($data['title'] ?? '')),

View File

@@ -5,20 +5,8 @@ namespace App\Post;
use PDO; 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 final class PostRepository implements PostRepositoryInterface
{ {
/**
* Fragment SELECT commun à toutes les requêtes de lecture (avec JOINs).
*/
private const SELECT = ' private const SELECT = '
SELECT posts.id, posts.title, posts.content, posts.slug, SELECT posts.id, posts.title, posts.content, posts.slug,
posts.author_id, posts.category_id, posts.created_at, posts.updated_at, 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 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) 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 public function findAll(?int $categoryId = null): array
{ {
if ($categoryId !== null) { 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 {
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC'); $stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
if ($stmt === false) { if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur posts a échoué.'); 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 public function findRecent(int $limit): array
{ {
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.id DESC LIMIT :limit'); $stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.id DESC LIMIT :limit');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute(); $stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
return array_map(fn ($row) => Post::fromArray($row), $rows);
} }
/**
* 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 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) { if ($categoryId !== null) {
$stmt = $this->db->prepare( $sql .= ' AND posts.category_id = :category_id';
self::SELECT . ' WHERE posts.author_id = :author_id AND posts.category_id = :category_id ORDER BY posts.id DESC' $params[':category_id'] = $categoryId;
);
$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]);
} }
$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 public function findBySlug(string $slug): ?Post
{ {
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug'); $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
$stmt->execute([':slug' => $slug]); $stmt->execute([':slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Post::fromArray($row) : null; 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 public function findById(int $id): ?Post
{ {
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id'); $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
$stmt->execute([':id' => $id]); $stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Post::fromArray($row) : null; 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 public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
{ {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare(
INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at) '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) VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)'
'); );
$stmt->execute([ $stmt->execute([
':title' => $post->getTitle(), ':title' => $post->getTitle(),
@@ -168,27 +176,14 @@ final class PostRepository implements PostRepositoryInterface
return (int) $this->db->lastInsertId(); 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 public function update(int $id, Post $post, string $slug, ?int $categoryId): int
{ {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare(
UPDATE posts 'UPDATE posts
SET title = :title, content = :content, slug = :slug, SET title = :title, content = :content, slug = :slug,
category_id = :category_id, updated_at = :updated_at category_id = :category_id, updated_at = :updated_at
WHERE id = :id WHERE id = :id'
'); );
$stmt->execute([ $stmt->execute([
':title' => $post->getTitle(), ':title' => $post->getTitle(),
@@ -202,13 +197,6 @@ final class PostRepository implements PostRepositoryInterface
return $stmt->rowCount(); 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 public function delete(int $id): int
{ {
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id'); $stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
@@ -217,23 +205,6 @@ final class PostRepository implements PostRepositoryInterface
return $stmt->rowCount(); 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 public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
{ {
$ftsQuery = $this->buildFtsQuery($query); $ftsQuery = $this->buildFtsQuery($query);
@@ -242,19 +213,50 @@ final class PostRepository implements PostRepositoryInterface
return []; 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 = ' $sql = '
SELECT p.id, p.title, p.content, p.slug, SELECT COUNT(*)
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 FROM posts_fts f
JOIN posts p ON p.id = f.rowid 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 WHERE posts_fts MATCH :query
'; ';
$params = [':query' => $ftsQuery]; $params = [':query' => $ftsQuery];
if ($categoryId !== null) { if ($categoryId !== null) {
@@ -267,27 +269,80 @@ final class PostRepository implements PostRepositoryInterface
$params[':author_id'] = $authorId; $params[':author_id'] = $authorId;
} }
$sql .= ' ORDER BY rank';
$stmt = $this->db->prepare($sql); $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. * @return array{0:string,1:array<string,mixed>}
*
* 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
*/ */
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 private function buildFtsQuery(string $input): string
{ {
$words = preg_split('/\s+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: []; $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 array<string, mixed> $params
*
* @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
*/ */
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'); foreach ($params as $key => $value) {
$stmt->execute([':slug' => $slug]); $stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$existingId = $stmt->fetchColumn(); }
if ($existingId === false) { /**
return false; * @param array<int, array<string, mixed>> $rows
} * @return Post[]
*/
$existingId = (int) $existingId; private function hydratePosts(array $rows): array
{
if ($excludeId !== null) { return array_map(fn ($row) => Post::fromArray($row), $rows);
return $existingId !== $excludeId;
}
return true;
} }
} }

View File

@@ -3,111 +3,49 @@ declare(strict_types=1);
namespace App\Post; 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 interface PostRepositoryInterface
{ {
/** /** @return Post[] */
* 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 findAll(?int $categoryId = null): array; public function findAll(?int $categoryId = null): array;
/** /** @return Post[] */
* Retourne les N articles les plus récents (flux RSS). public function findPage(int $limit, int $offset, ?int $categoryId = null): array;
*
* @param int $limit Nombre maximum d'articles à retourner public function countAll(?int $categoryId = null): int;
*
* @return Post[] /** @return Post[] */
*/
public function findRecent(int $limit): array; public function findRecent(int $limit): array;
/** /** @return Post[] */
* 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 findByUserId(int $userId, ?int $categoryId = null): array; public function findByUserId(int $userId, ?int $categoryId = null): array;
/** /** @return Post[] */
* Trouve un article par son slug URL. public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array;
*
* @param string $slug Le slug URL de l'article public function countByUserId(int $userId, ?int $categoryId = null): int;
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
public function findBySlug(string $slug): ?Post; 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; 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; 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; 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; public function delete(int $id): int;
/** /** @return Post[] */
* 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 search(string $query, ?int $categoryId = null, ?int $authorId = null): array; public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array;
/** /** @return Post[] */
* Vérifie si un slug est déjà utilisé par un autre article. public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array;
*
* @param string $slug Le slug à vérifier public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int;
* @param int|null $excludeId Identifiant à exclure (mise à jour)
*
* @return bool True si le slug est déjà pris
*/
public function slugExists(string $slug, ?int $excludeId = null): bool; 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;
} }

View File

@@ -5,82 +5,66 @@ namespace App\Post;
use App\Shared\Exception\NotFoundException; use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface; use App\Shared\Html\HtmlSanitizerInterface;
use App\Shared\Pagination\PaginatedResult;
use App\Shared\Util\SlugHelper; 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 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( public function __construct(
private readonly PostRepositoryInterface $postRepository, private readonly PostRepositoryInterface $postRepository,
private readonly HtmlSanitizerInterface $htmlSanitizer, 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 public function getAllPosts(?int $categoryId = null): array
{ {
return $this->postRepository->findAll($categoryId); return $this->postRepository->findAll($categoryId);
} }
/** /**
* Retourne les N articles les plus récents pour le flux RSS. * @return PaginatedResult<Post>
*
* @param int $limit Nombre maximum d'articles à retourner (défaut : 20)
*
* @return Post[]
*/ */
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 public function getRecentPosts(int $limit = 20): array
{ {
return $this->postRepository->findRecent($limit); 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 public function getPostsByUserId(int $userId, ?int $categoryId = null): array
{ {
return $this->postRepository->findByUserId($userId, $categoryId); return $this->postRepository->findByUserId($userId, $categoryId);
} }
/** /**
* Retourne un article par son slug URL. * @return PaginatedResult<Post>
*
* @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
*/ */
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 public function getPostBySlug(string $slug): Post
{ {
$post = $this->postRepository->findBySlug($slug); $post = $this->postRepository->findBySlug($slug);
@@ -92,15 +76,6 @@ final class PostService implements PostServiceInterface
return $post; 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 public function getPostById(int $id): Post
{ {
$post = $this->postRepository->findById($id); $post = $this->postRepository->findById($id);
@@ -112,22 +87,6 @@ final class PostService implements PostServiceInterface
return $post; 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 public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int
{ {
$sanitizedContent = $this->htmlSanitizer->sanitize($content); $sanitizedContent = $this->htmlSanitizer->sanitize($content);
@@ -137,29 +96,8 @@ final class PostService implements PostServiceInterface
return $this->postRepository->create($post, $slug, $authorId, $categoryId); return $this->postRepository->create($post, $slug, $authorId, $categoryId);
} }
/** public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void
* 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 {
$current = $this->postRepository->findById($id); $current = $this->postRepository->findById($id);
if ($current === null) { if ($current === null) {
@@ -168,7 +106,6 @@ final class PostService implements PostServiceInterface
$sanitizedContent = $this->htmlSanitizer->sanitize($content); $sanitizedContent = $this->htmlSanitizer->sanitize($content);
$post = new Post($id, $title, $sanitizedContent); $post = new Post($id, $title, $sanitizedContent);
$slugToUse = $current->getStoredSlug(); $slugToUse = $current->getStoredSlug();
$newSlugInput = trim($newSlugInput); $newSlugInput = trim($newSlugInput);
$cleanSlugInput = $this->normalizeSlugInput($newSlugInput); $cleanSlugInput = $this->normalizeSlugInput($newSlugInput);
@@ -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 public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array
{ {
return $this->postRepository->search($query, $categoryId, $authorId); return $this->postRepository->search($query, $categoryId, $authorId);
} }
/** /**
* Supprime un article. * @return PaginatedResult<Post>
*
* @param int $id Identifiant de l'article à supprimer
*
* @throws NotFoundException Si l'article n'existe plus au moment de la suppression
*/ */
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 public function deletePost(int $id): void
{ {
$affected = $this->postRepository->delete($id); $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 private function normalizeSlugInput(string $input): string
{ {
return SlugHelper::generate($input); 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 private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string
{ {
$slug = $baseSlug; $slug = $baseSlug;

View File

@@ -4,116 +4,49 @@ declare(strict_types=1);
namespace App\Post; namespace App\Post;
use App\Shared\Exception\NotFoundException; 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 interface PostServiceInterface
{ {
/** /** @return Post[] */
* 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[]
*/
public function getAllPosts(?int $categoryId = null): array; public function getAllPosts(?int $categoryId = null): array;
/** /**
* Retourne les articles les plus récents. * @return PaginatedResult<Post>
*
* @param int $limit Nombre maximum d'articles à retourner (défaut : 20)
*
* @return Post[]
*/ */
public function getAllPostsPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
/** @return Post[] */
public function getRecentPosts(int $limit = 20): array; public function getRecentPosts(int $limit = 20): array;
/** /** @return Post[] */
* 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[]
*/
public function getPostsByUserId(int $userId, ?int $categoryId = null): array; public function getPostsByUserId(int $userId, ?int $categoryId = null): array;
/** /**
* Retourne un article par son slug URL. * @return PaginatedResult<Post>
*
* @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
*/ */
public function getPostsByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
public function getPostBySlug(string $slug): Post; 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; 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; public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int;
/** public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void;
* 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;
/** /** @return Post[] */
* 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[]
*/
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array; public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array;
/** /**
* Supprime un article. * @return PaginatedResult<Post>
*
* @param int $id Identifiant de l'article à supprimer
*
* @throws NotFoundException Si l'article n'existe plus au moment de la suppression
*/ */
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; public function deletePost(int $id): void;
} }

View File

@@ -3,31 +3,10 @@ declare(strict_types=1);
namespace App\Shared\Http; 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 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 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) { if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true); session_regenerate_id(true);
} }
@@ -35,13 +14,10 @@ final class SessionManager implements SessionManagerInterface
$_SESSION['user_id'] = $userId; $_SESSION['user_id'] = $userId;
$_SESSION['username'] = $username; $_SESSION['username'] = $username;
$_SESSION['role'] = $role; $_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 public function getUserId(): ?int
{ {
return isset($_SESSION['user_id']) && $_SESSION['user_id'] !== '' return isset($_SESSION['user_id']) && $_SESSION['user_id'] !== ''
@@ -49,44 +25,21 @@ final class SessionManager implements SessionManagerInterface
: null; : null;
} }
/**
* Vérifie si une session utilisateur est active.
*
* @return bool True si un utilisateur est connecté
*/
public function isAuthenticated(): bool public function isAuthenticated(): bool
{ {
return $this->getUserId() !== null; 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 public function isAdmin(): bool
{ {
return ($_SESSION['role'] ?? '') === 'admin'; 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 public function isEditor(): bool
{ {
return ($_SESSION['role'] ?? '') === 'editor'; 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 public function destroy(): void
{ {
$_SESSION = []; $_SESSION = [];

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Shared\Pagination;
/**
* Résultat paginé générique.
*
* @template T
*/
class PaginatedResult
{
/** @var array<int, T> */
private readonly array $items;
/**
* @param array<int, T> $items
*/
public function __construct(
array $items,
private readonly int $total,
private readonly int $currentPage,
private readonly int $perPage,
) {
$this->items = $items;
}
/**
* @return array<int, T>
*/
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();
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Shared\Pagination;
use Psr\Http\Message\ServerRequestInterface;
final class PaginationPresenter
{
/**
* @param array<string, mixed> $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<mixed> $result
* @return array<string, mixed>
*/
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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
use App\User\User;
final class RoleAssignmentNotAllowedException extends \InvalidArgumentException
{
public function __construct(string $role)
{
parent::__construct(
"Le rôle '{$role}' ne peut pas être attribué depuis l'interface. Rôles autorisés : "
. implode(', ', User::assignableRoles())
);
}
}

View File

@@ -6,38 +6,14 @@ namespace App\User;
use App\Shared\Util\DateParser; use App\Shared\Util\DateParser;
use DateTime; use DateTime;
/**
* Modèle représentant un utilisateur de l'application.
*
* Encapsule les données et la validation d'un compte utilisateur.
* Ce modèle est immuable : toutes les propriétés sont en lecture seule
* après construction.
*/
final class User final class User
{ {
/**
* @var DateTime Date de création — toujours non nulle après construction
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
*/
private readonly DateTime $createdAt; private readonly DateTime $createdAt;
/**
* Rôles valides pour un utilisateur.
*/
public const ROLE_USER = 'user'; public const ROLE_USER = 'user';
public const ROLE_EDITOR = 'editor'; public const ROLE_EDITOR = 'editor';
public const ROLE_ADMIN = 'admin'; public const ROLE_ADMIN = 'admin';
/**
* @param int $id Identifiant en base (0 pour un nouvel utilisateur)
* @param string $username Nom d'utilisateur (normalisé en minuscules)
* @param string $email Adresse e-mail (normalisée en minuscules)
* @param string $passwordHash Hash bcrypt du mot de passe
* @param string $role Rôle de l'utilisateur : 'user', 'editor' ou 'admin'
* @param DateTime|null $createdAt Date de création (défaut : maintenant)
*
* @throws \InvalidArgumentException Si les données ne passent pas la validation
*/
public function __construct( public function __construct(
private readonly int $id, private readonly int $id,
private readonly string $username, private readonly string $username,
@@ -51,11 +27,7 @@ final class User
} }
/** /**
* Crée une instance depuis un tableau associatif (ligne de base de données). * @param array<string, mixed> $data
*
* @param array<string, mixed> $data Données issues de la base de données
*
* @return self L'instance hydratée
*/ */
public static function fromArray(array $data): self public static function fromArray(array $data): self
{ {
@@ -70,105 +42,69 @@ final class User
} }
/** /**
* Retourne l'identifiant de l'utilisateur. * @return string[]
*
* @return int L'identifiant en base (0 si non encore persisté)
*/ */
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 public function getId(): int
{ {
return $this->id; return $this->id;
} }
/**
* Retourne le nom d'utilisateur.
*
* @return string Le nom d'utilisateur normalisé en minuscules
*/
public function getUsername(): string public function getUsername(): string
{ {
return $this->username; return $this->username;
} }
/**
* Retourne l'adresse e-mail.
*
* @return string L'adresse e-mail normalisée en minuscules
*/
public function getEmail(): string public function getEmail(): string
{ {
return $this->email; return $this->email;
} }
/**
* Retourne le hash bcrypt du mot de passe.
*
* @return string Le hash bcrypt
*/
public function getPasswordHash(): string public function getPasswordHash(): string
{ {
return $this->passwordHash; return $this->passwordHash;
} }
/**
* Retourne le rôle de l'utilisateur.
*
* @return string 'user', 'editor' ou 'admin'
*/
public function getRole(): string public function getRole(): string
{ {
return $this->role; return $this->role;
} }
/**
* Indique si l'utilisateur a le rôle administrateur.
*
* @return bool True si l'utilisateur est administrateur
*/
public function isAdmin(): bool public function isAdmin(): bool
{ {
return $this->role === self::ROLE_ADMIN; 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 public function isEditor(): bool
{ {
return $this->role === self::ROLE_EDITOR; 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 public function getCreatedAt(): DateTime
{ {
return $this->createdAt; 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 private function validate(): void
{ {
if (mb_strlen($this->username) < 3) { if (mb_strlen($this->username) < 3) {
throw new \InvalidArgumentException( throw new \InvalidArgumentException("Le nom d'utilisateur doit contenir au moins 3 caractères");
"Le nom d'utilisateur doit contenir au moins 3 caractères"
);
} }
if (mb_strlen($this->username) > 50) { if (mb_strlen($this->username) > 50) {
throw new \InvalidArgumentException( throw new \InvalidArgumentException("Le nom d'utilisateur ne peut pas dépasser 50 caractères");
"Le nom d'utilisateur ne peut pas dépasser 50 caractères"
);
} }
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) { if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
@@ -176,15 +112,12 @@ final class User
} }
if ($this->passwordHash === '') { if ($this->passwordHash === '') {
throw new \InvalidArgumentException( throw new \InvalidArgumentException('Le hash du mot de passe ne peut pas être vide');
'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, self::allRoles(), true)) {
if (!in_array($this->role, $validRoles, true)) {
throw new \InvalidArgumentException( 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())
); );
} }
} }

View File

@@ -5,33 +5,20 @@ namespace App\User;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface; use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginationPresenter;
use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException; use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException; use App\User\Exception\InvalidRoleException;
use App\User\Exception\RoleAssignmentNotAllowedException;
use App\User\Exception\WeakPasswordException; use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig; use Slim\Views\Twig;
/**
* Contrôleur 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 final class UserController
{ {
/** private const PER_PAGE = 15;
* @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
*/
public function __construct( public function __construct(
private readonly Twig $view, private readonly Twig $view,
private readonly UserServiceInterface $userService, 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 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', [ 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(), 'currentUserId' => $this->sessionManager->getUserId(),
'assignableRoles' => User::assignableRoles(),
'error' => $this->flash->get('user_error'), 'error' => $this->flash->get('user_error'),
'success' => $this->flash->get('user_success'), '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 public function showCreate(Request $req, Response $res): Response
{ {
return $this->view->render($res, 'admin/users/form.twig', [ return $this->view->render($res, 'admin/users/form.twig', [
'assignableRoles' => User::assignableRoles(),
'error' => $this->flash->get('user_error'), '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 public function create(Request $req, Response $res): Response
{ {
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody(); $data = (array) $req->getParsedBody();
$username = trim((string) ($data['username'] ?? '')); $username = trim((string) ($data['username'] ?? ''));
$email = trim((string) ($data['email'] ?? '')); $email = trim((string) ($data['email'] ?? ''));
$password = trim((string) ($data['password'] ?? '')); $password = trim((string) ($data['password'] ?? ''));
$confirm = trim((string) ($data['password_confirm'] ?? '')); $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'] ?? '')); $rawRole = trim((string) ($data['role'] ?? ''));
$role = in_array($rawRole, $allowedRoles, true) $role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : User::ROLE_USER;
? $rawRole
: User::ROLE_USER;
if ($password !== $confirm) { if ($password !== $confirm) {
$this->flash->set('user_error', 'Les mots de passe ne correspondent pas'); $this->flash->set('user_error', 'Les mots de passe ne correspondent pas');
return $res->withHeader('Location', '/admin/users/create')->withStatus(302); return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
} }
try { try {
$this->userService->createUser($username, $email, $password, $role); $this->userService->createUser($username, $email, $password, $role);
$this->flash->set('user_success', "L'utilisateur « {$username} » a été créé avec succès"); $this->flash->set('user_success', "L'utilisateur « {$username} » a été créé avec succès");
return $res->withHeader('Location', '/admin/users')->withStatus(302); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} catch (DuplicateUsernameException) { } catch (DuplicateUsernameException) {
$this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris"); $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'); $this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée');
} catch (WeakPasswordException) { } catch (WeakPasswordException) {
$this->flash->set('user_error', 'Le mot de passe doit contenir au moins 8 caractères'); $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()); $this->flash->set('user_error', $e->getMessage());
} catch (\Throwable) { } catch (\Throwable) {
$this->flash->set('user_error', "Une erreur inattendue s'est produite"); $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. * @param array<string, mixed> $args
*
* 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<string, string> $args Les paramètres de route (id)
*
* @return Response Redirection vers /admin/users dans tous les cas
*/ */
public function updateRole(Request $req, Response $res, array $args): Response public function updateRole(Request $req, Response $res, array $args): Response
{ {
@@ -155,53 +94,40 @@ final class UserController
if ($user === null) { if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable'); $this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} }
if ($id === $this->sessionManager->getUserId()) { if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle'); $this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle');
return $res->withHeader('Location', '/admin/users')->withStatus(302); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} }
if ($user->isAdmin()) { 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); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} }
$allowedRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
/** @var array<string, mixed> $body */
$body = (array) $req->getParsedBody(); $body = (array) $req->getParsedBody();
$rawRole = trim((string) ($body['role'] ?? '')); $rawRole = trim((string) ($body['role'] ?? ''));
$role = in_array($rawRole, $allowedRoles, true) ? $rawRole : null; $role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : null;
if ($role === null) { if ($role === null) {
$this->flash->set('user_error', 'Rôle invalide'); $this->flash->set('user_error', 'Rôle invalide');
return $res->withHeader('Location', '/admin/users')->withStatus(302); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} }
try {
$this->userService->updateRole($id, $role); $this->userService->updateRole($id, $role);
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour"); $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); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} }
/** /**
* Supprime un utilisateur. * @param array<string, mixed> $args
*
* 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<string, string> $args Les paramètres de route (id)
*
* @return Response Redirection vers /admin/users dans tous les cas
*/ */
public function delete(Request $req, Response $res, array $args): Response public function delete(Request $req, Response $res, array $args): Response
{ {
@@ -210,19 +136,16 @@ final class UserController
if ($user === null) { if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable'); $this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} }
if ($user->isAdmin()) { if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé'); $this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé');
return $res->withHeader('Location', '/admin/users')->withStatus(302); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} }
if ($id === $this->sessionManager->getUserId()) { if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte'); $this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte');
return $res->withHeader('Location', '/admin/users')->withStatus(302); return $res->withHeader('Location', '/admin/users')->withStatus(302);
} }

View File

@@ -5,101 +5,75 @@ namespace App\User;
use PDO; 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 final class UserRepository implements UserRepositoryInterface
{ {
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db) 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 public function findAll(): array
{ {
$stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC'); $stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC');
if ($stmt === false) { if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur users a échoué.'); 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 public function findById(int $id): ?User
{ {
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id'); $stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $id]); $stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null; 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 public function findByUsername(string $username): ?User
{ {
$stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username'); $stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute([':username' => $username]); $stmt->execute([':username' => $username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null; 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 public function findByEmail(string $email): ?User
{ {
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email'); $stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $email]); $stmt->execute([':email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null; 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 public function create(User $user): int
{ {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare(
INSERT INTO users (username, email, password_hash, role, created_at) 'INSERT INTO users (username, email, password_hash, role, created_at)
VALUES (:username, :email, :password_hash, :role, :created_at) VALUES (:username, :email, :password_hash, :role, :created_at)'
'); );
$stmt->execute([ $stmt->execute([
':username' => $user->getUsername(), ':username' => $user->getUsername(),
@@ -112,36 +86,18 @@ final class UserRepository implements UserRepositoryInterface
return (int) $this->db->lastInsertId(); 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 public function updatePassword(int $id, string $newHash): void
{ {
$stmt = $this->db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id'); $stmt = $this->db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id');
$stmt->execute([':password_hash' => $newHash, ':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 public function updateRole(int $id, string $role): void
{ {
$stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id'); $stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id');
$stmt->execute([':role' => $role, ':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 public function delete(int $id): void
{ {
$stmt = $this->db->prepare('DELETE FROM users WHERE id = :id'); $stmt = $this->db->prepare('DELETE FROM users WHERE id = :id');

View File

@@ -3,78 +3,27 @@ declare(strict_types=1);
namespace App\User; 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 interface UserRepositoryInterface
{ {
/** /** @return User[] */
* Retourne tous les utilisateurs triés par date de création.
*
* @return User[]
*/
public function findAll(): array; public function findAll(): array;
/** /** @return User[] */
* Trouve un utilisateur par son identifiant. public function findPage(int $limit, int $offset): array;
*
* @param int $id Identifiant de l'utilisateur public function countAll(): int;
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?User; 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; 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; 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; 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; 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; 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; public function delete(int $id): void;
} }

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\User; namespace App\User;
use App\Shared\Exception\NotFoundException; use App\Shared\Exception\NotFoundException;
use App\Shared\Pagination\PaginatedResult;
use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException; use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException; use App\User\Exception\InvalidRoleException;
use App\User\Exception\RoleAssignmentNotAllowedException;
use App\User\Exception\WeakPasswordException; use App\User\Exception\WeakPasswordException;
final class UserService implements UserServiceInterface final class UserService implements UserServiceInterface
@@ -21,6 +23,23 @@ final class UserService implements UserServiceInterface
return $this->userRepository->findAll(); return $this->userRepository->findAll();
} }
/**
* @return PaginatedResult<User>
*/
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 public function findById(int $id): ?User
{ {
return $this->userRepository->findById($id); return $this->userRepository->findById($id);
@@ -34,7 +53,7 @@ final class UserService implements UserServiceInterface
public function updateRole(int $id, string $role): void public function updateRole(int $id, string $role): void
{ {
$this->assertValidRole($role); $this->assertRoleCanBeAssigned($role);
$this->requireExistingUser($id); $this->requireExistingUser($id);
$this->userRepository->updateRole($id, $role); $this->userRepository->updateRole($id, $role);
} }
@@ -45,7 +64,7 @@ final class UserService implements UserServiceInterface
$email = mb_strtolower(trim($email)); $email = mb_strtolower(trim($email));
$plainPassword = trim($plainPassword); $plainPassword = trim($plainPassword);
$this->assertValidRole($role); $this->assertRoleCanBeAssigned($role);
if ($this->userRepository->findByUsername($username)) { if ($this->userRepository->findByUsername($username)) {
throw new DuplicateUsernameException($username); throw new DuplicateUsernameException($username);
@@ -67,13 +86,15 @@ final class UserService implements UserServiceInterface
return $user; 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, User::allRoles(), true)) {
if (!in_array($role, $validRoles, true)) {
throw new InvalidRoleException($role); throw new InvalidRoleException($role);
} }
if (!in_array($role, User::assignableRoles(), true)) {
throw new RoleAssignmentNotAllowedException($role);
}
} }
private function requireExistingUser(int $id): User private function requireExistingUser(int $id): User

View File

@@ -3,67 +3,36 @@ declare(strict_types=1);
namespace App\User; namespace App\User;
use App\Shared\Pagination\PaginatedResult;
use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException; use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException; use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException; use App\User\Exception\WeakPasswordException;
/**
* Contrat 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 interface UserServiceInterface
{ {
/** /** @return User[] */
* Retourne tous les utilisateurs triés par date de création (ordre croissant).
*
* @return User[]
*/
public function findAll(): array; public function findAll(): array;
/** /**
* Trouve un utilisateur par son identifiant. * @return PaginatedResult<User>
*
* @param int $id Identifiant de l'utilisateur
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/ */
public function findPaginated(int $page, int $perPage): PaginatedResult;
public function findById(int $id): ?User; 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; public function delete(int $id): void;
/** /**
* Crée un nouveau compte utilisateur. * @throws DuplicateUsernameException
* * @throws DuplicateEmailException
* @param string $username Nom d'utilisateur souhaité (min. 3 caractères) * @throws WeakPasswordException
* @param string $email Adresse e-mail valide * @throws InvalidRoleException
* @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
*/ */
public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User; public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User;
/** /**
* Met à jour le rôle d'un utilisateur. * @throws InvalidRoleException
*
* @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
*/ */
public function updateRole(int $id, string $role): void; public function updateRole(int $id, string $role): void;
} }

View File

@@ -9,7 +9,7 @@ use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface; use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\WeakPasswordException; use App\User\Exception\WeakPasswordException;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase; use Tests\ControllerTestBase;
/** /**
* Tests unitaires pour AccountController. * Tests unitaires pour AccountController.
@@ -19,7 +19,7 @@ use Tests\ControllerTestCase;
* incorrect, erreur inattendue et succès. * incorrect, erreur inattendue et succès.
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AccountControllerTest extends ControllerTestCase final class AccountControllerTest extends ControllerTestBase
{ {
/** @var \Slim\Views\Twig&MockObject */ /** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view; private \Slim\Views\Twig $view;

View File

@@ -9,7 +9,7 @@ use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\User\User; use App\User\User;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase; use Tests\ControllerTestBase;
/** /**
* Tests unitaires pour AuthController. * Tests unitaires pour AuthController.
@@ -19,7 +19,7 @@ use Tests\ControllerTestCase;
* aucune base de données, aucun serveur HTTP n'est requis. * aucune base de données, aucun serveur HTTP n'est requis.
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class AuthControllerTest extends ControllerTestCase final class AuthControllerTest extends ControllerTestBase
{ {
/** @var \Slim\Views\Twig&MockObject */ /** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view; private \Slim\Views\Twig $view;

View File

@@ -12,7 +12,7 @@ use App\Shared\Http\FlashServiceInterface;
use App\User\Exception\WeakPasswordException; use App\User\Exception\WeakPasswordException;
use App\User\User; use App\User\User;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase; use Tests\ControllerTestBase;
/** /**
* Tests unitaires pour PasswordResetController. * 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) * - reset() couvre 5 chemins de sortie (token vide, mismatch, trop court, invalide, succès)
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetControllerTest extends ControllerTestCase final class PasswordResetControllerTest extends ControllerTestBase
{ {
/** @var \Slim\Views\Twig&MockObject */ /** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view; private \Slim\Views\Twig $view;

View File

@@ -7,8 +7,9 @@ use App\Category\Category;
use App\Category\CategoryController; use App\Category\CategoryController;
use App\Category\CategoryServiceInterface; use App\Category\CategoryServiceInterface;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\Shared\Pagination\PaginatedResult;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase; use Tests\ControllerTestBase;
/** /**
* Tests unitaires pour CategoryController. * Tests unitaires pour CategoryController.
@@ -18,7 +19,7 @@ use Tests\ControllerTestCase;
* suppression avec catégorie introuvable, succès et erreur métier. * suppression avec catégorie introuvable, succès et erreur métier.
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class CategoryControllerTest extends ControllerTestCase final class CategoryControllerTest extends ControllerTestBase
{ {
/** @var \Slim\Views\Twig&MockObject */ /** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view; private \Slim\Views\Twig $view;
@@ -51,7 +52,7 @@ final class CategoryControllerTest extends ControllerTestCase
*/ */
public function testIndexRendersWithCategories(): void public function testIndexRendersWithCategories(): void
{ {
$this->categoryService->method('findAll')->willReturn([]); $this->categoryService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20));
$this->view->expects($this->once()) $this->view->expects($this->once())
->method('render') ->method('render')

View File

@@ -19,7 +19,7 @@ use Slim\Psr7\Response as SlimResponse;
* sans passer par le routeur Slim les middlewares sont testés séparément. * sans passer par le routeur Slim les middlewares sont testés séparément.
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
abstract class ControllerTestCase extends TestCase abstract class ControllerTestBase extends TestCase
{ {
// ── Factories ──────────────────────────────────────────────────── // ── Factories ────────────────────────────────────────────────────

View File

@@ -11,9 +11,10 @@ use App\Media\MediaController;
use App\Media\MediaServiceInterface; use App\Media\MediaServiceInterface;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface; use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginatedResult;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Message\UploadedFileInterface; use Psr\Http\Message\UploadedFileInterface;
use Tests\ControllerTestCase; use Tests\ControllerTestBase;
/** /**
* Tests unitaires pour MediaController. * Tests unitaires pour MediaController.
@@ -24,7 +25,7 @@ use Tests\ControllerTestCase;
* - delete : introuvable, non-propriétaire, succès propriétaire, succès admin * - delete : introuvable, non-propriétaire, succès propriétaire, succès admin
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class MediaControllerTest extends ControllerTestCase final class MediaControllerTest extends ControllerTestBase
{ {
/** @var \Slim\Views\Twig&MockObject */ /** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view; private \Slim\Views\Twig $view;
@@ -65,8 +66,8 @@ final class MediaControllerTest extends ControllerTestCase
$this->sessionManager->method('isAdmin')->willReturn(true); $this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false); $this->sessionManager->method('isEditor')->willReturn(false);
$this->mediaService->expects($this->once())->method('findAll')->willReturn([]); $this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->mediaService->expects($this->never())->method('findByUserId'); $this->mediaService->expects($this->never())->method('findByUserIdPaginated');
$this->view->expects($this->once()) $this->view->expects($this->once())
->method('render') ->method('render')
@@ -86,8 +87,8 @@ final class MediaControllerTest extends ControllerTestCase
$this->sessionManager->method('isAdmin')->willReturn(false); $this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(true); $this->sessionManager->method('isEditor')->willReturn(true);
$this->mediaService->expects($this->once())->method('findAll')->willReturn([]); $this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->mediaService->expects($this->never())->method('findByUserId'); $this->mediaService->expects($this->never())->method('findByUserIdPaginated');
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse()); $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('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(42); $this->sessionManager->method('getUserId')->willReturn(42);
$this->mediaService->expects($this->once())->method('findByUserId')->with(42)->willReturn([]); $this->mediaService->expects($this->once())->method('findByUserIdPaginated')->with(42, 1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->mediaService->expects($this->never())->method('findAll'); $this->mediaService->expects($this->never())->method('findPaginated');
$this->controller->index($this->makeGet('/admin/media'), $this->makeResponse()); $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
} }

View File

@@ -6,6 +6,7 @@ namespace Tests\Media;
use App\Media\Media; use App\Media\Media;
use App\Media\MediaRepositoryInterface; use App\Media\MediaRepositoryInterface;
use App\Media\MediaService; use App\Media\MediaService;
use App\Post\PostRepositoryInterface;
use PDOException; use PDOException;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -19,6 +20,8 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
/** @var MediaRepositoryInterface&MockObject */ /** @var MediaRepositoryInterface&MockObject */
private MediaRepositoryInterface $repository; private MediaRepositoryInterface $repository;
private PostRepositoryInterface $postRepository;
private string $uploadDir; private string $uploadDir;
private MediaService $service; private MediaService $service;
@@ -26,10 +29,11 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->repository = $this->createMock(MediaRepositoryInterface::class); $this->repository = $this->createMock(MediaRepositoryInterface::class);
$this->postRepository = $this->createMock(PostRepositoryInterface::class);
$this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true); $this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true);
@mkdir($this->uploadDir, 0755, 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 protected function tearDown(): void
@@ -49,8 +53,8 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
$duplicate = new Media(77, 'existing.gif', '/media/existing.gif', $hash, 1); $duplicate = new Media(77, 'existing.gif', '/media/existing.gif', $hash, 1);
$this->repository->expects($this->exactly(2)) $this->repository->expects($this->exactly(2))
->method('findByHash') ->method('findByHashForUser')
->with($hash) ->with($hash, 1)
->willReturnOnConsecutiveCalls(null, $duplicate); ->willReturnOnConsecutiveCalls(null, $duplicate);
$this->repository->expects($this->once()) $this->repository->expects($this->once())

View File

@@ -3,26 +3,27 @@ declare(strict_types=1);
namespace Tests\Media; namespace Tests\Media;
use App\Media\MediaService;
use App\Media\MediaRepositoryInterface;
use App\Media\Exception\FileTooLargeException; use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\StorageException; use App\Media\Exception\StorageException;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use App\Post\PostRepositoryInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\StreamInterface; use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class MediaServiceEdgeCasesTest extends TestCase final class MediaServiceEdgeCasesTest extends TestCase
{ {
public function testRejectsWhenSizeUnknown(): void public function testRejectsWhenSizeUnknown(): void
{ {
$repo = $this->createMock(MediaRepositoryInterface::class); $repo = $this->createMock(MediaRepositoryInterface::class);
$postRepo = $this->createMock(PostRepositoryInterface::class);
$file = $this->createMock(UploadedFileInterface::class); $file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(null); $file->method('getSize')->willReturn(null);
$service = new MediaService($repo, '/tmp', '/media', 1000); $service = new MediaService($repo, $postRepo, '/tmp', '/media', 1000);
$this->expectException(StorageException::class); $this->expectException(StorageException::class);
$service->store($file, 1); $service->store($file, 1);
@@ -31,6 +32,7 @@ final class MediaServiceEdgeCasesTest extends TestCase
public function testRejectsWhenFileTooLarge(): void public function testRejectsWhenFileTooLarge(): void
{ {
$repo = $this->createMock(MediaRepositoryInterface::class); $repo = $this->createMock(MediaRepositoryInterface::class);
$postRepo = $this->createMock(PostRepositoryInterface::class);
$stream = $this->createMock(StreamInterface::class); $stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->willReturn('/tmp/file'); $stream->method('getMetadata')->willReturn('/tmp/file');
@@ -39,7 +41,7 @@ final class MediaServiceEdgeCasesTest extends TestCase
$file->method('getSize')->willReturn(999999); $file->method('getSize')->willReturn(999999);
$file->method('getStream')->willReturn($stream); $file->method('getStream')->willReturn($stream);
$service = new MediaService($repo, '/tmp', '/media', 100); $service = new MediaService($repo, $postRepo, '/tmp', '/media', 100);
$this->expectException(FileTooLargeException::class); $this->expectException(FileTooLargeException::class);
$service->store($file, 1); $service->store($file, 1);

View File

@@ -6,17 +6,18 @@ namespace Tests\Media;
use App\Media\Exception\InvalidMimeTypeException; use App\Media\Exception\InvalidMimeTypeException;
use App\Media\MediaRepositoryInterface; use App\Media\MediaRepositoryInterface;
use App\Media\MediaService; use App\Media\MediaService;
use App\Post\PostRepositoryInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\StreamInterface; use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class MediaServiceInvalidMimeTest extends TestCase final class MediaServiceInvalidMimeTest extends TestCase
{ {
public function testRejectsNonImageContentEvenWithImageLikeFilename(): void public function testRejectsNonImageContentEvenWithImageLikeFilename(): void
{ {
$repo = $this->createMock(MediaRepositoryInterface::class); $repo = $this->createMock(MediaRepositoryInterface::class);
$postRepo = $this->createMock(PostRepositoryInterface::class);
$tmpFile = tempnam(sys_get_temp_dir(), 'upload_'); $tmpFile = tempnam(sys_get_temp_dir(), 'upload_');
self::assertNotFalse($tmpFile); self::assertNotFalse($tmpFile);
@@ -30,7 +31,7 @@ final class MediaServiceInvalidMimeTest extends TestCase
$file->method('getStream')->willReturn($stream); $file->method('getStream')->willReturn($stream);
$file->method('getClientFilename')->willReturn('photo.png'); $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 { try {
$this->expectException(InvalidMimeTypeException::class); $this->expectException(InvalidMimeTypeException::class);

View File

@@ -6,6 +6,7 @@ namespace Tests\Media;
use App\Media\Exception\StorageException; use App\Media\Exception\StorageException;
use App\Media\MediaRepositoryInterface; use App\Media\MediaRepositoryInterface;
use App\Media\MediaService; use App\Media\MediaService;
use App\Post\PostRepositoryInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface; use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface; use Psr\Http\Message\UploadedFileInterface;
@@ -25,7 +26,9 @@ final class MediaServiceInvalidTempPathTest extends TestCase
$file->method('getSize')->willReturn(128); $file->method('getSize')->willReturn(128);
$file->method('getStream')->willReturn($stream); $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->expectException(StorageException::class);
$this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé'); $this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé');

View File

@@ -8,6 +8,7 @@ use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Media; use App\Media\Media;
use App\Media\MediaRepositoryInterface; use App\Media\MediaRepositoryInterface;
use App\Media\MediaService; use App\Media\MediaService;
use App\Post\PostRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface; use Psr\Http\Message\StreamInterface;
@@ -33,6 +34,8 @@ final class MediaServiceTest extends TestCase
/** @var MediaRepositoryInterface&MockObject */ /** @var MediaRepositoryInterface&MockObject */
private MediaRepositoryInterface $repository; private MediaRepositoryInterface $repository;
private PostRepositoryInterface $postRepository;
private string $uploadDir; private string $uploadDir;
private MediaService $service; private MediaService $service;
@@ -40,11 +43,13 @@ final class MediaServiceTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->repository = $this->createMock(MediaRepositoryInterface::class); $this->repository = $this->createMock(MediaRepositoryInterface::class);
$this->postRepository = $this->createMock(PostRepositoryInterface::class);
$this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid(); $this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid();
@mkdir($this->uploadDir, 0755, true); @mkdir($this->uploadDir, 0755, true);
$this->service = new MediaService( $this->service = new MediaService(
mediaRepository: $this->repository, mediaRepository: $this->repository,
postRepository: $this->postRepository,
uploadDir: $this->uploadDir, uploadDir: $this->uploadDir,
uploadUrl: '/media', uploadUrl: '/media',
maxSize: 5 * 1024 * 1024, maxSize: 5 * 1024 * 1024,
@@ -104,10 +109,13 @@ final class MediaServiceTest extends TestCase
public function testStoreReturnsDuplicateUrl(): void public function testStoreReturnsDuplicateUrl(): void
{ {
$tmpFile = $this->createMinimalJpeg(); $tmpFile = $this->createMinimalJpeg();
$hash = hash_file('sha256', $tmpFile);
$existing = new Media(7, 'existing.jpg', '/media/existing.jpg', $hash, 1); $existing = new Media(7, 'existing.jpg', '/media/existing.jpg', 'existing-hash', 1);
$this->repository->method('findByHash')->willReturn($existing); $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'); $this->repository->expects($this->never())->method('create');
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile)); $file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
@@ -128,7 +136,7 @@ final class MediaServiceTest extends TestCase
{ {
$tmpFile = $this->createMinimalJpeg(); $tmpFile = $this->createMinimalJpeg();
$this->repository->method('findByHash')->willReturn(null); $this->repository->method('findByHashForUser')->willReturn(null);
$this->repository->expects($this->once())->method('create'); $this->repository->expects($this->once())->method('create');
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile)); $file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));

View File

@@ -37,10 +37,16 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase
$realRepo = new PostRepository($this->db); $realRepo = new PostRepository($this->db);
$repo = new class($realRepo) implements PostRepositoryInterface { $repo = new class($realRepo) implements PostRepositoryInterface {
private bool $deleted = false; private bool $deleted = false;
public function __construct(private readonly PostRepository $inner) {} public function __construct(private readonly PostRepository $inner) {}
public function findAll(?int $categoryId = null): array { return $this->inner->findAll($categoryId); } 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 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 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 findBySlug(string $slug): ?Post { return $this->inner->findBySlug($slug); }
public function findById(int $id): ?Post { return $this->inner->findById($id); } 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); } 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 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 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 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 { $sanitizer = new class implements HtmlSanitizerInterface {

View File

@@ -11,9 +11,10 @@ use App\Post\PostServiceInterface;
use App\Shared\Exception\NotFoundException; use App\Shared\Exception\NotFoundException;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface; use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginatedResult;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Slim\Exception\HttpNotFoundException; use Slim\Exception\HttpNotFoundException;
use Tests\ControllerTestCase; use Tests\ControllerTestBase;
/** /**
* Tests unitaires pour PostController. * Tests unitaires pour PostController.
@@ -28,7 +29,7 @@ use Tests\ControllerTestCase;
* - delete() : 404, droits insuffisants, succès * - delete() : 404, droits insuffisants, succès
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PostControllerTest extends ControllerTestCase final class PostControllerTest extends ControllerTestBase
{ {
/** @var \Slim\Views\Twig&MockObject */ /** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view; private \Slim\Views\Twig $view;
@@ -73,8 +74,8 @@ final class PostControllerTest extends ControllerTestCase
*/ */
public function testIndexCallsGetAllPostsWithNoFilter(): void public function testIndexCallsGetAllPostsWithNoFilter(): void
{ {
$this->postService->expects($this->once())->method('getAllPosts')->with(null)->willReturn([]); $this->postService->expects($this->once())->method('getAllPostsPaginated')->with(1, 6, null)->willReturn(new PaginatedResult([], 0, 1, 6));
$this->postService->expects($this->never())->method('searchPosts'); $this->postService->expects($this->never())->method('searchPostsPaginated');
$res = $this->controller->index($this->makeGet('/'), $this->makeResponse()); $res = $this->controller->index($this->makeGet('/'), $this->makeResponse());
@@ -87,10 +88,10 @@ final class PostControllerTest extends ControllerTestCase
public function testIndexCallsSearchPostsWhenQueryParamPresent(): void public function testIndexCallsSearchPostsWhenQueryParamPresent(): void
{ {
$this->postService->expects($this->once()) $this->postService->expects($this->once())
->method('searchPosts') ->method('searchPostsPaginated')
->with('php', null) ->with('php', 1, 6, null)
->willReturn([]); ->willReturn(new PaginatedResult([], 0, 1, 6));
$this->postService->expects($this->never())->method('getAllPosts'); $this->postService->expects($this->never())->method('getAllPostsPaginated');
$this->controller->index($this->makeGet('/', ['q' => 'php']), $this->makeResponse()); $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->categoryService->expects($this->once())->method('findBySlug')->with('php')->willReturn($category);
$this->postService->expects($this->once()) $this->postService->expects($this->once())
->method('getAllPosts') ->method('getAllPostsPaginated')
->with(3) ->with(1, 6, 3)
->willReturn([]); ->willReturn(new PaginatedResult([], 0, 1, 6));
$this->controller->index( $this->controller->index(
$this->makeGet('/', ['categorie' => 'php']), $this->makeGet('/', ['categorie' => 'php']),
@@ -165,8 +166,8 @@ final class PostControllerTest extends ControllerTestCase
$this->sessionManager->method('isAdmin')->willReturn(true); $this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false); $this->sessionManager->method('isEditor')->willReturn(false);
$this->postService->expects($this->once())->method('getAllPosts')->willReturn([]); $this->postService->expects($this->once())->method('getAllPostsPaginated')->with(1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->postService->expects($this->never())->method('getPostsByUserId'); $this->postService->expects($this->never())->method('getPostsByUserIdPaginated');
$res = $this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse()); $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('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5); $this->sessionManager->method('getUserId')->willReturn(5);
$this->postService->expects($this->once())->method('getPostsByUserId')->with(5, null)->willReturn([]); $this->postService->expects($this->once())->method('getPostsByUserIdPaginated')->with(5, 1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->postService->expects($this->never())->method('getAllPosts'); $this->postService->expects($this->never())->method('getAllPostsPaginated');
$this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse()); $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->sessionManager->method('isEditor')->willReturn(false);
$this->postService->expects($this->once()) $this->postService->expects($this->once())
->method('searchPosts') ->method('searchPostsPaginated')
->with('php', null, null) ->with('php', 1, 12, null, null)
->willReturn([]); ->willReturn(new PaginatedResult([], 0, 1, 12));
$this->controller->admin( $this->controller->admin(
$this->makeGet('/admin/posts', ['q' => 'php']), $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. * Crée une entité Post de test avec les paramètres minimaux.
* *
* Nommé buildPostEntity (et non makePost) pour ne pas masquer * 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( private function buildPostEntity(
int $id, int $id,

View File

@@ -143,9 +143,7 @@ final class PostRepositoryTest extends TestCase
$stmt->expects($this->once()) $stmt->expects($this->once())
->method('execute') ->method('execute')
->with($this->callback(fn (array $p): bool => ->with([':category_id' => 3]);
isset($p[':category_id']) && $p[':category_id'] === 3
));
$this->repository->findAll(3); $this->repository->findAll(3);
} }
@@ -217,9 +215,7 @@ final class PostRepositoryTest extends TestCase
$stmt->expects($this->once()) $stmt->expects($this->once())
->method('execute') ->method('execute')
->with($this->callback(fn (array $p): bool => ->with([':author_id' => 7]);
isset($p[':author_id']) && $p[':author_id'] === 7
));
$this->repository->findByUserId(7); $this->repository->findByUserId(7);
} }
@@ -234,11 +230,7 @@ final class PostRepositoryTest extends TestCase
$stmt->expects($this->once()) $stmt->expects($this->once())
->method('execute') ->method('execute')
->with($this->callback(fn (array $p): bool => ->with([':author_id' => 7, ':category_id' => 3]);
isset($p[':author_id'], $p[':category_id'])
&& $p[':author_id'] === 7
&& $p[':category_id'] === 3
));
$this->repository->findByUserId(7, 3); $this->repository->findByUserId(7, 3);
} }

View File

@@ -7,7 +7,7 @@ use App\Post\Post;
use App\Post\PostServiceInterface; use App\Post\PostServiceInterface;
use App\Post\RssController; use App\Post\RssController;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase; use Tests\ControllerTestBase;
/** /**
* Tests unitaires pour RssController. * Tests unitaires pour RssController.
@@ -20,7 +20,7 @@ use Tests\ControllerTestCase;
* - Appel à getRecentPosts() avec la constante FEED_LIMIT (20) * - Appel à getRecentPosts() avec la constante FEED_LIMIT (20)
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class RssControllerTest extends ControllerTestCase final class RssControllerTest extends ControllerTestBase
{ {
/** @var PostServiceInterface&MockObject */ /** @var PostServiceInterface&MockObject */
private PostServiceInterface $postService; private PostServiceInterface $postService;

View File

@@ -5,6 +5,7 @@ namespace Tests\User;
use App\Shared\Http\FlashServiceInterface; use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface; use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginatedResult;
use App\User\Exception\DuplicateEmailException; use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException; use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\WeakPasswordException; use App\User\Exception\WeakPasswordException;
@@ -12,7 +13,7 @@ use App\User\User;
use App\User\UserController; use App\User\UserController;
use App\User\UserServiceInterface; use App\User\UserServiceInterface;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase; use Tests\ControllerTestBase;
/** /**
* Tests unitaires pour UserController. * Tests unitaires pour UserController.
@@ -25,7 +26,7 @@ use Tests\ControllerTestCase;
* - delete() : introuvable, cible admin, soi-même, succès * - delete() : introuvable, cible admin, soi-même, succès
*/ */
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class UserControllerTest extends ControllerTestCase final class UserControllerTest extends ControllerTestBase
{ {
/** @var \Slim\Views\Twig&MockObject */ /** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view; private \Slim\Views\Twig $view;
@@ -63,7 +64,7 @@ final class UserControllerTest extends ControllerTestCase
*/ */
public function testIndexRendersWithUserList(): void 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->sessionManager->method('getUserId')->willReturn(1);
$this->view->expects($this->once()) $this->view->expects($this->once())

View File

@@ -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')] #[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')]
public function testUpdateRoleAcceptsAllValidRoles(string $role): void public function testUpdateRoleAcceptsAllValidRoles(string $role): void
@@ -254,7 +254,6 @@ final class UserServiceTest extends TestCase
return [ return [
'user' => [User::ROLE_USER], 'user' => [User::ROLE_USER],
'editor' => [User::ROLE_EDITOR], 'editor' => [User::ROLE_EDITOR],
'admin' => [User::ROLE_ADMIN],
]; ];
} }

View File

@@ -54,9 +54,7 @@
<input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}"> <input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}"> <input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
<button type="submit" class="btn btn--sm btn--danger" <button type="submit" class="btn btn--sm btn--danger"
onclick="return confirm('Supprimer la catégorie « {{ category.name }} » ? onclick="return confirm('Supprimer la catégorie « {{ category.name }} » ?\n\nCette action est impossible si des articles lui sont rattachés.')">
Cette action est impossible si des articles lui sont rattachés.')">
Supprimer Supprimer
</button> </button>
</form> </form>
@@ -66,6 +64,8 @@ Cette action est impossible si des articles lui sont rattachés.')">
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
{% else %} {% else %}
<p><em>Aucune catégorie créée.</em></p> <p><em>Aucune catégorie créée.</em></p>
{% endif %} {% endif %}

View File

@@ -21,12 +21,14 @@
<tr> <tr>
<th>Aperçu</th> <th>Aperçu</th>
<th>URL</th> <th>URL</th>
<th>Usage</th>
<th>Uploadé le</th> <th>Uploadé le</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in media %} {% for item in media %}
{% set usage = mediaUsage[item.id] ?? {'count': 0, 'posts': []} %}
<tr> <tr>
<td data-label="Aperçu"> <td data-label="Aperçu">
<div class="upload"> <div class="upload">
@@ -47,6 +49,18 @@
</div> </div>
</div> </div>
</td> </td>
<td data-label="Usage">
{% if usage.count > 0 %}
<strong>{{ usage.count }} article{{ usage.count > 1 ? 's' : '' }}</strong>
<ul>
{% for post in usage.posts %}
<li><a href="/admin/posts/edit/{{ post.id }}">{{ post.title }}</a></li>
{% endfor %}
</ul>
{% else %}
<span class="admin-table__muted">Aucun</span>
{% endif %}
</td>
<td data-label="Uploadé le">{{ item.createdAt|date("d/m/Y H:i") }}</td> <td data-label="Uploadé le">{{ item.createdAt|date("d/m/Y H:i") }}</td>
<td data-label="Actions"> <td data-label="Actions">
<div class="u-inline-actions"> <div class="u-inline-actions">
@@ -54,7 +68,8 @@
<input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}"> <input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}"> <input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
<button type="submit" class="btn btn--sm btn--danger" <button type="submit" class="btn btn--sm btn--danger"
onclick="return confirm('Supprimer ce fichier ?\n\nAttention : s\'il est utilisé dans un article, l\'image n\'apparaîtra plus.')"> {% if usage.count > 0 %}disabled title="Supprimez ou remplacez d'abord l'image dans les articles référencés"{% endif %}
onclick="return confirm('Supprimer ce fichier ?')">
Supprimer Supprimer
</button> </button>
</form> </form>
@@ -64,6 +79,8 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
{% else %} {% else %}
<p><em>Aucun fichier uploadé.</em></p> <p><em>Aucun fichier uploadé.</em></p>
{% endif %} {% endif %}

View File

@@ -25,8 +25,8 @@
{% if searchQuery %} {% if searchQuery %}
<p class="search-bar__info"> <p class="search-bar__info">
{% if posts is not empty %} {% if totalPosts > 0 %}
{{ posts|length }} résultat{{ posts|length > 1 ? 's' : '' }} pour « {{ searchQuery }} » {{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} »
{% else %} {% else %}
Aucun résultat pour « {{ searchQuery }} » Aucun résultat pour « {{ searchQuery }} »
{% endif %} {% endif %}
@@ -74,8 +74,7 @@
<td data-label="Titre"><strong>{{ post.title }}</strong></td> <td data-label="Titre"><strong>{{ post.title }}</strong></td>
<td data-label="Catégorie"> <td data-label="Catégorie">
{% if post.categoryName %} {% if post.categoryName %}
<a href="/admin/posts?categorie={{ post.categorySlug }}" <a href="/admin/posts?categorie={{ post.categorySlug }}" class="badge badge--category">{{ post.categoryName }}</a>
class="badge badge--category">{{ post.categoryName }}</a>
{% else %} {% else %}
<span class="admin-table__muted">—</span> <span class="admin-table__muted">—</span>
{% endif %} {% endif %}
@@ -101,6 +100,8 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
{% else %} {% else %}
<p><em>{% if searchQuery %}Aucun résultat pour « {{ searchQuery }} ».{% else %}Aucun article à gérer.{% endif %}</em></p> <p><em>{% if searchQuery %}Aucun résultat pour « {{ searchQuery }} ».{% else %}Aucun article à gérer.{% endif %}</em></p>
{% endif %} {% endif %}

View File

@@ -54,10 +54,12 @@
<label for="role" class="form-container__label"> <label for="role" class="form-container__label">
<span>Rôle</span> <span>Rôle</span>
<select id="role" name="role" class="form-container__select"> <select id="role" name="role" class="form-container__select">
<option value="user">Utilisateur</option> {% for role in assignableRoles %}
<option value="editor">Éditeur</option> <option value="{{ role }}">{% if role == 'editor' %}Éditeur{% else %}Utilisateur{% endif %}</option>
{% endfor %}
</select> </select>
</label> </label>
<small class="form-container__hint">Le rôle administrateur reste réservé au provisionnement initial.</small>
</p> </p>
<div class="form-container__actions"> <div class="form-container__actions">

View File

@@ -58,9 +58,11 @@
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}"> <input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
<div class="u-inline-actions"> <div class="u-inline-actions">
<select name="role" class="admin-table__role-select"> <select name="role" class="admin-table__role-select">
<option value="user" {% if user.role == 'user' %}selected{% endif %}>Utilisateur</option> {% for role in assignableRoles %}
<option value="editor" {% if user.role == 'editor' %}selected{% endif %}>Éditeur</option> <option value="{{ role }}" {% if user.role == role %}selected{% endif %}>
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option> {% if role == 'editor' %}Éditeur{% else %}Utilisateur{% endif %}
</option>
{% endfor %}
</select> </select>
<button type="submit" class="btn btn--sm btn--secondary">Modifier</button> <button type="submit" class="btn btn--sm btn--secondary">Modifier</button>
</div> </div>
@@ -89,6 +91,8 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
{% else %} {% else %}
<p><em>Aucun utilisateur.</em></p> <p><em>Aucun utilisateur.</em></p>
{% endif %} {% endif %}

View File

@@ -26,8 +26,8 @@
{% if searchQuery %} {% if searchQuery %}
<p class="search-bar__info"> <p class="search-bar__info">
{% if posts is not empty %} {% if totalPosts > 0 %}
{{ posts|length }} résultat{{ posts|length > 1 ? 's' : '' }} pour « {{ searchQuery }} » {{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} »
{% else %} {% else %}
Aucun résultat pour « {{ searchQuery }} » Aucun résultat pour « {{ searchQuery }} »
{% endif %} {% endif %}
@@ -53,7 +53,6 @@
{% for post in posts %} {% for post in posts %}
{% set thumb = post_thumbnail(post) %} {% set thumb = post_thumbnail(post) %}
<article class="card"> <article class="card">
<a href="{{ post_url(post) }}" class="card__thumb-link" tabindex="-1" aria-hidden="true"> <a href="{{ post_url(post) }}" class="card__thumb-link" tabindex="-1" aria-hidden="true">
{% if thumb %} {% if thumb %}
<img class="card__thumb" src="{{ thumb }}" alt=""> <img class="card__thumb" src="{{ thumb }}" alt="">
@@ -85,10 +84,11 @@
<a href="{{ post_url(post) }}" class="card__actions-link">Lire la suite →</a> <a href="{{ post_url(post) }}" class="card__actions-link">Lire la suite →</a>
</div> </div>
</div> </div>
</article> </article>
{% else %} {% else %}
<p>Aucun article publié{% if searchQuery %} pour « {{ searchQuery }} »{% elseif activeCategory %} dans cette catégorie{% endif %}.</p> <p>Aucun article publié{% if searchQuery %} pour « {{ searchQuery }} »{% elseif activeCategory %} dans cette catégorie{% endif %}.</p>
{% endfor %} {% endfor %}
</div> </div>
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,29 @@
{% if pagination.totalPages > 1 %}
<nav class="pagination" aria-label="Pagination">
{% if pagination.hasPrevious %}
<a href="{{ pagination.previousUrl }}" class="btn btn--sm btn--secondary">← Précédent</a>
{% else %}
<span class="admin-table__muted">← Précédent</span>
{% endif %}
<span class="pagination__summary">
Page {{ pagination.currentPage }} sur {{ pagination.totalPages }}
</span>
<span class="pagination__pages">
{% for page in pagination.pages %}
{% if page.current %}
<strong>{{ page.number }}</strong>
{% else %}
<a href="{{ page.url }}">{{ page.number }}</a>
{% endif %}
{% endfor %}
</span>
{% if pagination.hasNext %}
<a href="{{ pagination.nextUrl }}" class="btn btn--sm btn--secondary">Suivant →</a>
{% else %}
<span class="admin-table__muted">Suivant →</span>
{% endif %}
</nav>
{% endif %}