first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\AccountController;
use App\Auth\AuthServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\WeakPasswordException;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour AccountController.
*
* Couvre showChangePassword() et changePassword() :
* mots de passe non identiques, mot de passe faible, mot de passe actuel
* incorrect, erreur inattendue et succès.
*/
final class AccountControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var AuthServiceInterface&MockObject */
private AuthServiceInterface $authService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private AccountController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->authService = $this->createMock(AuthServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->controller = new AccountController(
$this->view,
$this->authService,
$this->flash,
$this->sessionManager,
);
}
// ── showChangePassword ───────────────────────────────────────────
/**
* showChangePassword() doit rendre le formulaire de changement de mot de passe.
*/
public function testShowChangePasswordRendersForm(): void
{
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'pages/account/password-change.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->showChangePassword($this->makeGet('/account/password'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── changePassword ───────────────────────────────────────────────
/**
* changePassword() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
*/
public function testChangePasswordRedirectsWhenPasswordMismatch(): void
{
$this->flash->expects($this->once())->method('set')
->with('password_error', 'Les mots de passe ne correspondent pas');
$req = $this->makePost('/account/password', [
'current_password' => 'oldpass',
'new_password' => 'newpass1',
'new_password_confirm' => 'newpass2',
]);
$res = $this->controller->changePassword($req, $this->makeResponse());
$this->assertRedirectTo($res, '/account/password');
}
/**
* changePassword() ne doit pas appeler authService si les mots de passe ne correspondent pas.
*/
public function testChangePasswordDoesNotCallServiceOnMismatch(): void
{
$this->authService->expects($this->never())->method('changePassword');
$req = $this->makePost('/account/password', [
'current_password' => 'old',
'new_password' => 'aaa',
'new_password_confirm' => 'bbb',
]);
$this->controller->changePassword($req, $this->makeResponse());
}
/**
* changePassword() doit afficher une erreur si le nouveau mot de passe est trop court.
*/
public function testChangePasswordRedirectsOnWeakPassword(): void
{
$this->sessionManager->method('getUserId')->willReturn(1);
$this->authService->method('changePassword')->willThrowException(new WeakPasswordException());
$this->flash->expects($this->once())->method('set')
->with('password_error', $this->stringContains('8 caractères'));
$req = $this->makePost('/account/password', [
'current_password' => 'old',
'new_password' => 'short',
'new_password_confirm' => 'short',
]);
$res = $this->controller->changePassword($req, $this->makeResponse());
$this->assertRedirectTo($res, '/account/password');
}
/**
* changePassword() doit afficher une erreur si le mot de passe actuel est incorrect.
*/
public function testChangePasswordRedirectsOnWrongCurrentPassword(): void
{
$this->sessionManager->method('getUserId')->willReturn(1);
$this->authService->method('changePassword')
->willThrowException(new \InvalidArgumentException('Mot de passe actuel incorrect'));
$this->flash->expects($this->once())->method('set')
->with('password_error', 'Le mot de passe actuel est incorrect');
$req = $this->makePost('/account/password', [
'current_password' => 'wrong',
'new_password' => 'newpassword',
'new_password_confirm' => 'newpassword',
]);
$res = $this->controller->changePassword($req, $this->makeResponse());
$this->assertRedirectTo($res, '/account/password');
}
/**
* changePassword() doit afficher une erreur générique en cas d'exception inattendue.
*/
public function testChangePasswordRedirectsOnUnexpectedError(): void
{
$this->sessionManager->method('getUserId')->willReturn(1);
$this->authService->method('changePassword')
->willThrowException(new \RuntimeException('DB error'));
$this->flash->expects($this->once())->method('set')
->with('password_error', $this->stringContains('inattendue'));
$req = $this->makePost('/account/password', [
'current_password' => 'old',
'new_password' => 'newpassword',
'new_password_confirm' => 'newpassword',
]);
$res = $this->controller->changePassword($req, $this->makeResponse());
$this->assertRedirectTo($res, '/account/password');
}
/**
* changePassword() doit afficher un message de succès et rediriger en cas de succès.
*/
public function testChangePasswordRedirectsWithSuccessFlash(): void
{
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('password_success', $this->stringContains('Mot de passe modifié'));
$req = $this->makePost('/account/password', [
'current_password' => 'oldpass123',
'new_password' => 'newpass123',
'new_password_confirm' => 'newpass123',
]);
$res = $this->controller->changePassword($req, $this->makeResponse());
$this->assertRedirectTo($res, '/account/password');
}
/**
* changePassword() doit utiliser 0 comme userId de repli si la session est vide.
*/
public function testChangePasswordUsesZeroAsUserIdFallback(): void
{
$this->sessionManager->method('getUserId')->willReturn(null);
$this->authService->expects($this->once())
->method('changePassword')
->with(0, $this->anything(), $this->anything());
$req = $this->makePost('/account/password', [
'current_password' => 'old',
'new_password' => 'newpassword',
'new_password_confirm' => 'newpassword',
]);
$this->controller->changePassword($req, $this->makeResponse());
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\AuthController;
use App\Auth\AuthServiceInterface;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface;
use App\User\User;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour AuthController.
*
* Couvre showLogin(), login() et logout() sans passer par le routeur Slim.
* AuthService, FlashService et Twig sont mockés — aucune session PHP,
* aucune base de données, aucun serveur HTTP n'est requis.
*/
final class AuthControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var AuthServiceInterface&MockObject */
private AuthServiceInterface $authService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
private AuthController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->authService = $this->createMock(AuthServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->controller = new AuthController(
$this->view,
$this->authService,
$this->flash,
new ClientIpResolver(['*']),
);
}
// ── showLogin ────────────────────────────────────────────────────
/**
* showLogin() doit rediriger vers /admin/posts si l'utilisateur est déjà connecté.
*/
public function testShowLoginRedirectsWhenAlreadyLoggedIn(): void
{
$this->authService->method('isLoggedIn')->willReturn(true);
$res = $this->controller->showLogin($this->makeGet('/auth/login'), $this->makeResponse());
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* showLogin() doit rendre le formulaire de connexion si l'utilisateur n'est pas connecté.
*/
public function testShowLoginRendersFormWhenNotLoggedIn(): void
{
$this->authService->method('isLoggedIn')->willReturn(false);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'pages/auth/login.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->showLogin($this->makeGet('/auth/login'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── login ────────────────────────────────────────────────────────
/**
* login() doit rediriger avec un message flash si l'IP est verrouillée.
*/
public function testLoginRedirectsWhenRateLimited(): void
{
$this->authService->method('checkRateLimit')->willReturn(5);
$this->flash->expects($this->once())->method('set')
->with('login_error', $this->stringContains('Trop de tentatives'));
$req = $this->makePost('/auth/login', [], ['REMOTE_ADDR' => '10.0.0.1']);
$res = $this->controller->login($req, $this->makeResponse());
$this->assertRedirectTo($res, '/auth/login');
}
/**
* login() doit conjuguer correctement le singulier/pluriel dans le message de rate limit.
*/
public function testLoginRateLimitMessageIsSingularForOneMinute(): void
{
$this->authService->method('checkRateLimit')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('login_error', $this->logicalNot($this->stringContains('minutes')));
$req = $this->makePost('/auth/login', [], ['REMOTE_ADDR' => '10.0.0.1']);
$this->controller->login($req, $this->makeResponse());
}
/**
* login() doit enregistrer l'échec et rediriger si les identifiants sont invalides.
*/
public function testLoginRecordsFailureOnInvalidCredentials(): void
{
$this->authService->method('checkRateLimit')->willReturn(0);
$this->authService->method('authenticate')->willReturn(null);
$this->authService->expects($this->once())->method('recordFailure');
$this->flash->expects($this->once())->method('set')
->with('login_error', 'Identifiants invalides');
$req = $this->makePost('/auth/login', ['username' => 'alice', 'password' => 'wrong']);
$res = $this->controller->login($req, $this->makeResponse());
$this->assertRedirectTo($res, '/auth/login');
}
/**
* login() doit ouvrir la session et rediriger vers /admin/posts en cas de succès.
*/
public function testLoginRedirectsToAdminOnSuccess(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->authService->method('checkRateLimit')->willReturn(0);
$this->authService->method('authenticate')->willReturn($user);
$this->authService->expects($this->once())->method('resetRateLimit');
$this->authService->expects($this->once())->method('login')->with($user);
$req = $this->makePost('/auth/login', ['username' => 'alice', 'password' => 'secret']);
$res = $this->controller->login($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* login() doit utiliser '0.0.0.0' comme IP de repli si REMOTE_ADDR est absent.
*/
public function testLoginFallsBackToDefaultIpWhenRemoteAddrMissing(): void
{
$this->authService->method('checkRateLimit')->willReturn(0);
$this->authService->method('authenticate')->willReturn(null);
$this->authService->expects($this->once())
->method('checkRateLimit')
->with('0.0.0.0');
$req = $this->makePost('/auth/login');
$this->controller->login($req, $this->makeResponse());
}
// ── logout ───────────────────────────────────────────────────────
/**
* logout() doit détruire la session et rediriger vers l'accueil.
*/
public function testLogoutDestroysSessionAndRedirects(): void
{
$this->authService->expects($this->once())->method('logout');
$res = $this->controller->logout($this->makePost('/auth/logout'), $this->makeResponse());
$this->assertRedirectTo($res, '/');
}
}

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\AuthService;
use App\Auth\LoginAttemptRepositoryInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\UserRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour la protection anti-brute force de AuthService.
*
* Vérifie le comportement de checkRateLimit(), recordFailure() et
* resetRateLimit(). Les constantes testées correspondent aux valeurs
* définies dans AuthService :
* - MAX_ATTEMPTS = 5 : nombre d'échecs avant verrouillage
* - LOCK_MINUTES = 15 : durée du verrouillage en minutes
*/
final class AuthServiceRateLimitTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private AuthService $service;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new AuthService(
$this->userRepository,
$this->sessionManager,
$this->loginAttemptRepository,
);
}
// ── checkRateLimit ─────────────────────────────────────────────
/**
* checkRateLimit() doit retourner 0 si l'IP n'a aucune entrée en base.
*/
public function testCheckRateLimitUnknownIpReturnsZero(): void
{
$this->loginAttemptRepository->method('deleteExpired');
$this->loginAttemptRepository->method('findByIp')->willReturn(null);
$result = $this->service->checkRateLimit('192.168.1.1');
$this->assertSame(0, $result);
}
/**
* checkRateLimit() doit retourner 0 si l'IP a des tentatives
* mais n'est pas encore verrouillée (locked_until = null).
*/
public function testCheckRateLimitNoLockReturnsZero(): void
{
$this->loginAttemptRepository->method('deleteExpired');
$this->loginAttemptRepository->method('findByIp')->willReturn([
'ip' => '192.168.1.1',
'attempts' => 3,
'locked_until' => null,
'updated_at' => date('Y-m-d H:i:s'),
]);
$result = $this->service->checkRateLimit('192.168.1.1');
$this->assertSame(0, $result);
}
/**
* checkRateLimit() doit retourner le nombre de minutes restantes
* si l'IP est actuellement verrouillée.
*/
public function testCheckRateLimitLockedIpReturnsRemainingMinutes(): void
{
$lockedUntil = date('Y-m-d H:i:s', time() + 10 * 60);
$this->loginAttemptRepository->method('deleteExpired');
$this->loginAttemptRepository->method('findByIp')->willReturn([
'ip' => '192.168.1.1',
'attempts' => 5,
'locked_until' => $lockedUntil,
'updated_at' => date('Y-m-d H:i:s'),
]);
$result = $this->service->checkRateLimit('192.168.1.1');
$this->assertGreaterThan(0, $result);
$this->assertLessThanOrEqual(15, $result);
}
/**
* checkRateLimit() doit retourner au minimum 1 minute
* même si le verrouillage expire dans quelques secondes.
*/
public function testCheckRateLimitReturnsAtLeastOneMinute(): void
{
$lockedUntil = date('Y-m-d H:i:s', time() + 30);
$this->loginAttemptRepository->method('deleteExpired');
$this->loginAttemptRepository->method('findByIp')->willReturn([
'ip' => '192.168.1.1',
'attempts' => 5,
'locked_until' => $lockedUntil,
'updated_at' => date('Y-m-d H:i:s'),
]);
$result = $this->service->checkRateLimit('192.168.1.1');
$this->assertSame(1, $result);
}
/**
* checkRateLimit() doit retourner 0 si le verrouillage est expiré.
*/
public function testCheckRateLimitExpiredLockReturnsZero(): void
{
$lockedUntil = date('Y-m-d H:i:s', time() - 60);
$this->loginAttemptRepository->method('deleteExpired');
$this->loginAttemptRepository->method('findByIp')->willReturn([
'ip' => '192.168.1.1',
'attempts' => 5,
'locked_until' => $lockedUntil,
'updated_at' => date('Y-m-d H:i:s'),
]);
$result = $this->service->checkRateLimit('192.168.1.1');
$this->assertSame(0, $result);
}
/**
* checkRateLimit() doit toujours appeler deleteExpired() avant la vérification.
*/
public function testCheckRateLimitCallsDeleteExpired(): void
{
$this->loginAttemptRepository
->expects($this->once())
->method('deleteExpired');
$this->loginAttemptRepository->method('findByIp')->willReturn(null);
$this->service->checkRateLimit('192.168.1.1');
}
// ── recordFailure ──────────────────────────────────────────────
/**
* recordFailure() doit déléguer avec MAX_ATTEMPTS = 5 et LOCK_MINUTES = 15.
*/
public function testRecordFailureDelegatesWithConstants(): void
{
$this->loginAttemptRepository
->expects($this->once())
->method('recordFailure')
->with('192.168.1.1', 5, 15);
$this->service->recordFailure('192.168.1.1');
}
/**
* recordFailure() doit transmettre l'adresse IP exacte au dépôt.
*/
public function testRecordFailurePassesIpAddress(): void
{
$ip = '10.0.0.42';
$this->loginAttemptRepository
->expects($this->once())
->method('recordFailure')
->with($ip, $this->anything(), $this->anything());
$this->service->recordFailure($ip);
}
// ── resetRateLimit ─────────────────────────────────────────────
/**
* resetRateLimit() doit appeler resetForIp() avec l'adresse IP fournie.
*/
public function testResetRateLimitCallsResetForIp(): void
{
$ip = '192.168.1.1';
$this->loginAttemptRepository
->expects($this->once())
->method('resetForIp')
->with($ip);
$this->service->resetRateLimit($ip);
}
/**
* resetRateLimit() ne doit pas appeler recordFailure() ni deleteExpired().
*/
public function testResetRateLimitCallsNothingElse(): void
{
$this->loginAttemptRepository->expects($this->never())->method('recordFailure');
$this->loginAttemptRepository->expects($this->never())->method('deleteExpired');
$this->loginAttemptRepository->method('resetForIp');
$this->service->resetRateLimit('192.168.1.1');
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\AuthService;
use App\Auth\LoginAttemptRepositoryInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour AuthService.
*
* Vérifie l'authentification, le changement de mot de passe et la gestion
* des sessions. La création de comptes est couverte par UserServiceTest.
* Les dépendances sont remplacées par des mocks via leurs interfaces pour
* isoler le service.
*/
final class AuthServiceTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private AuthService $service;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new AuthService(
$this->userRepository,
$this->sessionManager,
$this->loginAttemptRepository,
);
}
// ── authenticate ───────────────────────────────────────────────
/**
* authenticate() doit retourner l'utilisateur si les identifiants sont corrects.
*/
public function testAuthenticateValidCredentials(): void
{
$password = 'motdepasse1';
$user = $this->makeUser('alice', 'alice@example.com', $password);
$this->userRepository->method('findByUsername')->with('alice')->willReturn($user);
$result = $this->service->authenticate('alice', $password);
$this->assertSame($user, $result);
}
/**
* authenticate() doit normaliser le nom d'utilisateur en minuscules.
*/
public function testAuthenticateNormalizesUsername(): void
{
$password = 'motdepasse1';
$user = $this->makeUser('alice', 'alice@example.com', $password);
$this->userRepository->method('findByUsername')->with('alice')->willReturn($user);
$result = $this->service->authenticate('ALICE', $password);
$this->assertNotNull($result);
}
/**
* authenticate() doit retourner null si l'utilisateur est introuvable.
*/
public function testAuthenticateUnknownUser(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$result = $this->service->authenticate('inconnu', 'motdepasse1');
$this->assertNull($result);
}
/**
* authenticate() doit retourner null si le mot de passe est incorrect.
*/
public function testAuthenticateWrongPassword(): void
{
$user = $this->makeUser('alice', 'alice@example.com', 'bonmotdepasse');
$this->userRepository->method('findByUsername')->willReturn($user);
$result = $this->service->authenticate('alice', 'mauvaismdp');
$this->assertNull($result);
}
// ── changePassword ─────────────────────────────────────────────
/**
* changePassword() doit mettre à jour le hash si les données sont valides.
*/
public function testChangePasswordWithValidData(): void
{
$password = 'ancienmdp1';
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
$this->userRepository->method('findById')->with(1)->willReturn($user);
$this->userRepository->expects($this->once())->method('updatePassword')->with(1);
$this->service->changePassword(1, $password, 'nouveaumdp1');
}
/**
* changePassword() doit lever une exception si le mot de passe actuel est incorrect.
*/
public function testChangePasswordWrongCurrentPassword(): void
{
$user = $this->makeUser('alice', 'alice@example.com', 'bonmotdepasse', 1);
$this->userRepository->method('findById')->willReturn($user);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/actuel incorrect/');
$this->service->changePassword(1, 'mauvaismdp', 'nouveaumdp1');
}
/**
* changePassword() avec exactement 8 caractères doit réussir (frontière basse).
*/
public function testChangePasswordMinimumLengthNewPassword(): void
{
$password = 'ancienmdp1';
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
$this->userRepository->method('findById')->willReturn($user);
$this->userRepository->expects($this->once())->method('updatePassword');
// Ne doit pas lever d'exception
$this->service->changePassword(1, $password, '12345678');
$this->addToAssertionCount(1);
}
/**
* changePassword() doit lever WeakPasswordException si le nouveau mot de passe est trop court.
*/
public function testChangePasswordTooShortNewPasswordThrowsWeakPasswordException(): void
{
$password = 'ancienmdp1';
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
$this->userRepository->method('findById')->willReturn($user);
$this->expectException(WeakPasswordException::class);
$this->service->changePassword(1, $password, '1234567');
}
/**
* changePassword() doit lever NotFoundException si l'utilisateur est introuvable.
*/
public function testChangePasswordUnknownUserThrowsNotFoundException(): void
{
$this->userRepository->method('findById')->willReturn(null);
$this->expectException(NotFoundException::class);
$this->service->changePassword(99, 'ancienmdp1', 'nouveaumdp1');
}
// ── login / logout / isLoggedIn ────────────────────────────────
/**
* login() doit appeler SessionManager::setUser() avec les bonnes données.
*/
public function testLoginCallsSetUser(): void
{
$user = $this->makeUser('alice', 'alice@example.com', 'secret', 7, User::ROLE_ADMIN);
$this->sessionManager->expects($this->once())
->method('setUser')
->with(7, 'alice', User::ROLE_ADMIN);
$this->service->login($user);
}
/**
* logout() doit appeler SessionManager::destroy().
*/
public function testLogoutCallsDestroy(): void
{
$this->sessionManager->expects($this->once())->method('destroy');
$this->service->logout();
}
/**
* isLoggedIn() doit déléguer à SessionManager::isAuthenticated().
*/
public function testIsLoggedInDelegatesToSessionManager(): void
{
$this->sessionManager->method('isAuthenticated')->willReturn(true);
$this->assertTrue($this->service->isLoggedIn());
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni.
*
* @param string $username Nom d'utilisateur
* @param string $email Adresse e-mail
* @param string $password Mot de passe en clair (haché en bcrypt)
* @param int $id Identifiant (défaut : 1)
* @param string $role Rôle (défaut : 'user')
*/
private function makeUser(
string $username,
string $email,
string $password = 'motdepasse1',
int $id = 1,
string $role = User::ROLE_USER,
): User {
return new User(
$id,
$username,
$email,
password_hash($password, PASSWORD_BCRYPT),
$role,
);
}
}

View File

@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\LoginAttemptRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour LoginAttemptRepository.
*
* Vérifie la logique interne de gestion des tentatives de connexion :
* lecture par IP, UPSERT atomique via prepare/execute, réinitialisation
* et nettoyage des entrées expirées.
*
* recordFailure() utilise un UPSERT SQL atomique (ON CONFLICT DO UPDATE)
* pour éliminer la race condition du pattern SELECT + INSERT/UPDATE.
* Les tests vérifient que prepare() est appelé avec le bon SQL et que
* execute() reçoit les bons paramètres.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
final class LoginAttemptRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private LoginAttemptRepository $repository;
/**
* Initialise le mock PDO et le dépôt avant chaque test.
*/
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new LoginAttemptRepository($this->db);
}
// ── Helper ─────────────────────────────────────────────────────
private function stmtOk(): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetch')->willReturn(false);
return $stmt;
}
// ── findByIp ───────────────────────────────────────────────────
/**
* findByIp() doit retourner null si aucune entrée n'existe pour cette IP.
*/
public function testFindByIpReturnsNullWhenMissing(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetch')->willReturn(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByIp('192.168.1.1'));
}
/**
* findByIp() doit retourner le tableau associatif si une entrée existe.
*/
public function testFindByIpReturnsRowWhenFound(): void
{
$row = [
'ip' => '192.168.1.1',
'attempts' => 3,
'locked_until' => null,
'updated_at' => date('Y-m-d H:i:s'),
];
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetch')->willReturn($row);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame($row, $this->repository->findByIp('192.168.1.1'));
}
/**
* findByIp() exécute avec la bonne IP.
*/
public function testFindByIpQueriesWithCorrectIp(): void
{
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':ip' => '10.0.0.1']);
$this->repository->findByIp('10.0.0.1');
}
// ── recordFailure — UPSERT atomique ────────────────────────────
/**
* recordFailure() doit utiliser un UPSERT SQL (ON CONFLICT DO UPDATE)
* via prepare/execute — garantie de l'atomicité.
*/
public function testRecordFailureUsesUpsertSql(): void
{
$stmt = $this->stmtOk();
$this->db->expects($this->once())
->method('prepare')
->with($this->logicalAnd(
$this->stringContains('INSERT INTO login_attempts'),
$this->stringContains('ON CONFLICT'),
))
->willReturn($stmt);
$this->repository->recordFailure('192.168.1.1', 5, 15);
}
/**
* recordFailure() doit passer la bonne IP au paramètre :ip.
*/
public function testRecordFailurePassesCorrectIp(): void
{
$ip = '10.0.0.42';
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool => $p[':ip'] === $ip));
$this->repository->recordFailure($ip, 5, 15);
}
/**
* recordFailure() doit passer le seuil maxAttempts sous les deux alias :max1 et :max2.
*
* PDO interdit les paramètres nommés dupliqués dans une même requête ;
* le seuil est donc passé deux fois avec des noms distincts.
*/
public function testRecordFailurePassesMaxAttemptsUnderBothAliases(): void
{
$max = 7;
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool =>
$p[':max1'] === $max && $p[':max2'] === $max
));
$this->repository->recordFailure('192.168.1.1', $max, 15);
}
/**
* recordFailure() doit calculer locked_until dans la fenêtre attendue.
*
* :lock1 et :lock2 doivent être égaux et correspondre à
* maintenant + lockMinutes (± 5 secondes de tolérance d'exécution).
*/
public function testRecordFailureLockedUntilIsInCorrectWindow(): void
{
$lockMinutes = 15;
$before = time() + $lockMinutes * 60 - 5;
$after = time() + $lockMinutes * 60 + 5;
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $p) use ($before, $after): bool {
foreach ([':lock1', ':lock2'] as $key) {
if (!isset($p[$key])) {
return false;
}
$ts = strtotime($p[$key]);
if ($ts < $before || $ts > $after) {
return false;
}
}
return $p[':lock1'] === $p[':lock2'];
}));
$this->repository->recordFailure('192.168.1.1', 5, $lockMinutes);
}
/**
* recordFailure() doit horodater :now1 et :now2 dans la fenêtre de l'instant présent.
*/
public function testRecordFailureNowParamsAreCurrentTime(): void
{
$before = time() - 2;
$after = time() + 2;
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $p) use ($before, $after): bool {
foreach ([':now1', ':now2'] as $key) {
if (!isset($p[$key])) {
return false;
}
$ts = strtotime($p[$key]);
if ($ts < $before || $ts > $after) {
return false;
}
}
return $p[':now1'] === $p[':now2'];
}));
$this->repository->recordFailure('192.168.1.1', 5, 15);
}
// ── resetForIp ─────────────────────────────────────────────────
/**
* resetForIp() doit préparer un DELETE ciblant la bonne IP.
*/
public function testResetForIpCallsDeleteWithCorrectIp(): void
{
$ip = '10.0.0.42';
$stmt = $this->stmtOk();
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM login_attempts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':ip' => $ip]);
$this->repository->resetForIp($ip);
}
// ── deleteExpired ──────────────────────────────────────────────
/**
* deleteExpired() doit préparer un DELETE ciblant locked_until expiré
* et lier une date au format 'Y-m-d H:i:s' comme paramètre :now.
*/
public function testDeleteExpiredExecutesQueryWithCurrentTimestamp(): void
{
$stmt = $this->stmtOk();
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM login_attempts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $params): bool {
return isset($params[':now'])
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $params[':now']);
}));
$this->repository->deleteExpired();
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Middleware\AdminMiddleware;
use App\Auth\Middleware\AuthMiddleware;
use App\Auth\Middleware\EditorMiddleware;
use App\Shared\Http\SessionManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
final class MiddlewareTest extends TestCase
{
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private ServerRequestInterface $request;
protected function setUp(): void
{
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->request = (new ServerRequestFactory())->createServerRequest('GET', '/admin');
}
public function testAuthMiddlewareRedirectsGuests(): void
{
$this->sessionManager->method('isAuthenticated')->willReturn(false);
$middleware = new AuthMiddleware($this->sessionManager);
$response = $middleware->process($this->request, $this->makeHandler());
self::assertSame(302, $response->getStatusCode());
self::assertSame('/auth/login', $response->getHeaderLine('Location'));
}
public function testAuthMiddlewareDelegatesWhenAuthenticated(): void
{
$this->sessionManager->method('isAuthenticated')->willReturn(true);
$middleware = new AuthMiddleware($this->sessionManager);
$response = $middleware->process($this->request, $this->makeHandler(204));
self::assertSame(204, $response->getStatusCode());
}
public function testAdminMiddlewareRedirectsNonAdmins(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$middleware = new AdminMiddleware($this->sessionManager);
$response = $middleware->process($this->request, $this->makeHandler());
self::assertSame(302, $response->getStatusCode());
self::assertSame('/admin/posts', $response->getHeaderLine('Location'));
}
public function testAdminMiddlewareDelegatesForAdmins(): void
{
$this->sessionManager->method('isAdmin')->willReturn(true);
$middleware = new AdminMiddleware($this->sessionManager);
$response = $middleware->process($this->request, $this->makeHandler(204));
self::assertSame(204, $response->getStatusCode());
}
public function testEditorMiddlewareRedirectsWhenNeitherAdminNorEditor(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$middleware = new EditorMiddleware($this->sessionManager);
$response = $middleware->process($this->request, $this->makeHandler());
self::assertSame(302, $response->getStatusCode());
self::assertSame('/admin/posts', $response->getHeaderLine('Location'));
}
public function testEditorMiddlewareDelegatesForEditors(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(true);
$middleware = new EditorMiddleware($this->sessionManager);
$response = $middleware->process($this->request, $this->makeHandler(204));
self::assertSame(204, $response->getStatusCode());
}
private function makeHandler(int $status = 200): RequestHandlerInterface
{
return new class ($status) implements RequestHandlerInterface {
public function __construct(private readonly int $status)
{
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return new Response($this->status);
}
};
}
}

View File

@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\AuthServiceInterface;
use App\Auth\PasswordResetController;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour PasswordResetController.
*
* Couvre le flux en deux étapes :
* 1. showForgot() / forgot() — demande de réinitialisation
* 2. showReset() / reset() — saisie du nouveau mot de passe
*
* Points clés :
* - forgot() vérifie le rate limit par IP avant tout traitement
* - forgot() enregistre systématiquement une tentative (anti-canal-caché)
* - forgot() affiche toujours un message de succès générique (anti-énumération)
* - showReset() valide le token avant d'afficher le formulaire
* - reset() couvre 5 chemins de sortie (token vide, mismatch, trop court, invalide, succès)
*/
final class PasswordResetControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var PasswordResetServiceInterface&MockObject */
private PasswordResetServiceInterface $passwordResetService;
/** @var AuthServiceInterface&MockObject */
private AuthServiceInterface $authService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
private PasswordResetController $controller;
private const BASE_URL = 'https://example.com';
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class);
$this->authService = $this->createMock(AuthServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
// Par défaut : IP non verrouillée
$this->authService->method('checkRateLimit')->willReturn(0);
$this->controller = new PasswordResetController(
$this->view,
$this->passwordResetService,
$this->authService,
$this->flash,
self::BASE_URL,
);
}
// ── showForgot ───────────────────────────────────────────────────
/**
* showForgot() doit rendre le formulaire de demande de réinitialisation.
*/
public function testShowForgotRendersForm(): void
{
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'pages/auth/password-forgot.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->showForgot($this->makeGet('/password/forgot'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── forgot — rate limiting ────────────────────────────────────────
/**
* forgot() doit rediriger avec une erreur générique si l'IP est verrouillée.
*
* Le message ne mentionne pas l'email pour ne pas révéler qu'une demande
* pour cette adresse a déjà été traitée.
*
* Note : le mock setUp() est reconfiguré ici via un nouveau mock pour éviter
* le conflit avec le willReturn(0) global — en PHPUnit 11 la première
* configuration prend la main sur les suivantes pour le même matcher any().
*/
public function testForgotRedirectsWhenRateLimited(): void
{
$authService = $this->createMock(AuthServiceInterface::class);
$authService->method('checkRateLimit')->willReturn(10);
$controller = new PasswordResetController(
$this->view,
$this->passwordResetService,
$authService,
$this->flash,
self::BASE_URL,
);
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('Trop de demandes'));
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$res = $controller->forgot($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* forgot() ne doit pas appeler requestReset() si l'IP est verrouillée.
*/
public function testForgotDoesNotCallServiceWhenRateLimited(): void
{
$authService = $this->createMock(AuthServiceInterface::class);
$authService->method('checkRateLimit')->willReturn(5);
$controller = new PasswordResetController(
$this->view,
$this->passwordResetService,
$authService,
$this->flash,
self::BASE_URL,
);
$this->passwordResetService->expects($this->never())->method('requestReset');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$controller->forgot($req, $this->makeResponse());
}
/**
* forgot() doit enregistrer une tentative pour chaque demande, quel que soit le résultat.
*
* Réinitialiser le compteur uniquement en cas de succès constituerait un canal
* caché permettant de déduire si l'adresse email est enregistrée.
*/
public function testForgotAlwaysRecordsFailure(): void
{
$this->authService->expects($this->once())->method('recordFailure');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$this->controller->forgot($req, $this->makeResponse());
}
/**
* forgot() ne doit jamais réinitialiser le compteur de rate limit.
*
* Un reset sur succès révélerait l'existence du compte (canal caché).
*/
public function testForgotNeverResetsRateLimit(): void
{
$this->authService->expects($this->never())->method('resetRateLimit');
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$this->controller->forgot($req, $this->makeResponse());
}
// ── forgot — comportement nominal ────────────────────────────────
/**
* forgot() doit afficher un message de succès générique même si l'email est inconnu.
*
* Protection contre l'énumération des comptes : le comportement externe
* ne doit pas révéler si l'adresse est enregistrée.
*/
public function testForgotAlwaysShowsGenericSuccessMessage(): void
{
// requestReset() est void — aucun stub nécessaire, le mock ne lève pas d'exception par défaut
$this->flash->expects($this->once())->method('set')
->with('reset_success', $this->stringContains('Si cette adresse'));
$req = $this->makePost('/password/forgot', ['email' => 'unknown@example.com']);
$res = $this->controller->forgot($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* forgot() doit afficher une erreur et rediriger si l'envoi d'email échoue.
*/
public function testForgotRedirectsWithErrorOnMailFailure(): void
{
$this->passwordResetService->method('requestReset')
->willThrowException(new \RuntimeException('SMTP error'));
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('erreur est survenue'));
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$res = $this->controller->forgot($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* forgot() doit transmettre l'APP_URL au service lors de la demande.
*/
public function testForgotPassesBaseUrlToService(): void
{
$this->passwordResetService->expects($this->once())
->method('requestReset')
->with($this->anything(), self::BASE_URL);
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
$this->controller->forgot($req, $this->makeResponse());
}
// ── showReset ────────────────────────────────────────────────────
/**
* showReset() doit rediriger avec une erreur si le paramètre token est absent.
*/
public function testShowResetRedirectsWhenTokenMissing(): void
{
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('manquant'));
$res = $this->controller->showReset($this->makeGet('/password/reset'), $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* showReset() doit rediriger avec une erreur si le token est invalide ou expiré.
*/
public function testShowResetRedirectsWhenTokenInvalid(): void
{
$this->passwordResetService->method('validateToken')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('invalide'));
$req = $this->makeGet('/password/reset', ['token' => 'invalid-token']);
$res = $this->controller->showReset($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* showReset() doit rendre le formulaire si le token est valide.
*/
public function testShowResetRendersFormWhenTokenValid(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->passwordResetService->method('validateToken')->willReturn($user);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'pages/auth/password-reset.twig', $this->anything())
->willReturnArgument(0);
$req = $this->makeGet('/password/reset', ['token' => 'valid-token']);
$res = $this->controller->showReset($req, $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── reset ────────────────────────────────────────────────────────
/**
* reset() doit rediriger vers /password/forgot si le token est absent du corps.
*/
public function testResetRedirectsToForgotWhenTokenMissing(): void
{
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('manquant'));
$req = $this->makePost('/password/reset', [
'token' => '',
'new_password' => 'newpass123',
'new_password_confirm' => 'newpass123',
]);
$res = $this->controller->reset($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/forgot');
}
/**
* reset() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
*/
public function testResetRedirectsWhenPasswordMismatch(): void
{
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('correspondent pas'));
$req = $this->makePost('/password/reset', [
'token' => 'abc123',
'new_password' => 'newpass1',
'new_password_confirm' => 'newpass2',
]);
$res = $this->controller->reset($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/reset?token=abc123');
}
/**
* reset() doit rediriger avec une erreur si le nouveau mot de passe est trop court.
*/
public function testResetRedirectsOnWeakPassword(): void
{
$this->passwordResetService->method('resetPassword')
->willThrowException(new WeakPasswordException());
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('8 caractères'));
$req = $this->makePost('/password/reset', [
'token' => 'abc123',
'new_password' => 'short',
'new_password_confirm' => 'short',
]);
$res = $this->controller->reset($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/reset?token=abc123');
}
/**
* reset() doit rediriger avec une erreur si le token est invalide ou expiré.
*/
public function testResetRedirectsOnInvalidToken(): void
{
$this->passwordResetService->method('resetPassword')
->willThrowException(new InvalidResetTokenException('Token invalide'));
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('invalide'));
$req = $this->makePost('/password/reset', [
'token' => 'expired-token',
'new_password' => 'newpass123',
'new_password_confirm' => 'newpass123',
]);
$res = $this->controller->reset($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/reset?token=expired-token');
}
/**
* reset() doit rediriger avec une erreur générique en cas d'exception inattendue.
*/
public function testResetRedirectsOnUnexpectedError(): void
{
$this->passwordResetService->method('resetPassword')
->willThrowException(new \RuntimeException('DB error'));
$this->flash->expects($this->once())->method('set')
->with('reset_error', $this->stringContains('inattendue'));
$req = $this->makePost('/password/reset', [
'token' => 'abc123',
'new_password' => 'newpass123',
'new_password_confirm' => 'newpass123',
]);
$res = $this->controller->reset($req, $this->makeResponse());
$this->assertRedirectTo($res, '/password/reset?token=abc123');
}
/**
* reset() doit flasher un succès et rediriger vers /auth/login en cas de succès.
*/
public function testResetRedirectsToLoginOnSuccess(): void
{
$this->flash->expects($this->once())->method('set')
->with('login_success', $this->stringContains('réinitialisé'));
$req = $this->makePost('/password/reset', [
'token' => 'valid-token',
'new_password' => 'newpass123',
'new_password_confirm' => 'newpass123',
]);
$res = $this->controller->reset($req, $this->makeResponse());
$this->assertRedirectTo($res, '/auth/login');
}
/**
* reset() doit encoder correctement le token dans l'URL de redirection en cas d'erreur.
*/
public function testResetEncodesTokenInRedirectUrl(): void
{
$this->passwordResetService->method('resetPassword')
->willThrowException(new WeakPasswordException());
$this->flash->method('set');
$token = 'tok/en+with=special&chars';
$req = $this->makePost('/password/reset', [
'token' => $token,
'new_password' => 'x',
'new_password_confirm' => 'x',
]);
$res = $this->controller->reset($req, $this->makeResponse());
$this->assertSame(
'/password/reset?token=' . urlencode($token),
$res->getHeaderLine('Location'),
);
}
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\PasswordResetRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PasswordResetRepository.
*
* Vérifie que chaque méthode construit le bon SQL avec les bons paramètres,
* notamment la logique de non-suppression des tokens (used_at) et la
* condition AND used_at IS NULL pour les tokens actifs.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
final class PasswordResetRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private PasswordResetRepository $repository;
/**
* Initialise le mock PDO et le dépôt avant chaque test.
*/
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new PasswordResetRepository($this->db);
}
// ── Helper ─────────────────────────────────────────────────────
private function stmtOk(array|false $fetchReturn = false): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetch')->willReturn($fetchReturn);
return $stmt;
}
// ── create ─────────────────────────────────────────────────────
/**
* create() doit préparer un INSERT sur 'password_resets'
* avec le user_id, le token_hash et la date d'expiration fournis.
*/
public function testCreateCallsInsertWithCorrectData(): void
{
$userId = 1;
$tokenHash = hash('sha256', 'montokenbrut');
$expiresAt = date('Y-m-d H:i:s', time() + 3600);
$stmt = $this->stmtOk();
$this->db->method('prepare')
->with($this->stringContains('INSERT INTO password_resets'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($userId, $tokenHash, $expiresAt): bool {
return $data[':user_id'] === $userId
&& $data[':token_hash'] === $tokenHash
&& $data[':expires_at'] === $expiresAt
&& isset($data[':created_at']);
}));
$this->repository->create($userId, $tokenHash, $expiresAt);
}
/**
* create() doit renseigner :created_at au format 'Y-m-d H:i:s'.
*/
public function testCreateSetsCreatedAt(): void
{
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return isset($data[':created_at'])
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':created_at']);
}));
$this->repository->create(1, 'hash', date('Y-m-d H:i:s', time() + 3600));
}
// ── findActiveByHash ───────────────────────────────────────────
/**
* findActiveByHash() doit retourner null si aucun token actif ne correspond au hash.
*/
public function testFindActiveByHashReturnsNullWhenMissing(): void
{
$stmt = $this->stmtOk(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findActiveByHash('hashquinaexistepas'));
}
/**
* findActiveByHash() doit retourner la ligne si un token actif correspond au hash.
*/
public function testFindActiveByHashReturnsRowWhenFound(): void
{
$tokenHash = hash('sha256', 'montokenbrut');
$row = [
'id' => 1,
'user_id' => 42,
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
'created_at' => date('Y-m-d H:i:s'),
];
$stmt = $this->stmtOk($row);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame($row, $this->repository->findActiveByHash($tokenHash));
}
/**
* findActiveByHash() doit inclure AND used_at IS NULL dans le SQL
* pour n'obtenir que les tokens non consommés.
*/
public function testFindActiveByHashFiltersOnNullUsedAt(): void
{
$tokenHash = hash('sha256', 'montokenbrut');
$stmt = $this->stmtOk(false);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('used_at IS NULL'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':token_hash' => $tokenHash]);
$this->repository->findActiveByHash($tokenHash);
}
// ── invalidateByUserId ─────────────────────────────────────────
/**
* invalidateByUserId() doit préparer un UPDATE renseignant :used_at
* pour tous les tokens non consommés de l'utilisateur.
*/
public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void
{
$userId = 42;
$stmt = $this->stmtOk();
$this->db->method('prepare')
->with($this->stringContains('UPDATE password_resets'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($userId): bool {
return $data[':user_id'] === $userId
&& isset($data[':used_at'])
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':used_at']);
}));
$this->repository->invalidateByUserId($userId);
}
/**
* invalidateByUserId() doit inclure AND used_at IS NULL dans le SQL
* pour ne cibler que les tokens encore actifs.
*/
public function testInvalidateByUserIdFiltersOnActiveTokens(): void
{
$stmt = $this->stmtOk();
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('used_at IS NULL'))
->willReturn($stmt);
$this->repository->invalidateByUserId(1);
}
/**
* invalidateByUserId() ne doit pas utiliser DELETE — les tokens
* sont invalidés via used_at pour conserver la traçabilité.
*/
public function testInvalidateByUserIdNeverCallsDelete(): void
{
$stmt = $this->stmtOk();
$this->db->method('prepare')
->with($this->stringContains('UPDATE'))
->willReturn($stmt);
$this->db->expects($this->never())->method('exec');
$this->repository->invalidateByUserId(1);
}
// ── consumeActiveToken ────────────────────────────────────────
/**
* consumeActiveToken() doit utiliser UPDATE ... RETURNING pour consommer
* et retourner le token en une seule opération atomique.
*/
public function testConsumeActiveTokenUsesAtomicUpdateReturning(): void
{
$row = ['id' => 1, 'user_id' => 42, 'token_hash' => 'hash', 'used_at' => null];
$stmt = $this->stmtOk($row);
$this->db->expects($this->once())
->method('prepare')
->with($this->callback(fn (string $sql): bool =>
str_contains($sql, 'UPDATE password_resets')
&& str_contains($sql, 'used_at IS NULL')
&& str_contains($sql, 'RETURNING *')
))
->willReturn($stmt);
$result = $this->repository->consumeActiveToken('hash', '2024-01-01 00:00:00');
$this->assertSame($row, $result);
}
/**
* consumeActiveToken() retourne null si aucun token actif ne correspond.
*/
public function testConsumeActiveTokenReturnsNullWhenNoRowMatches(): void
{
$stmt = $this->stmtOk(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->consumeActiveToken('missing', '2024-01-01 00:00:00'));
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetRepository;
use App\Auth\PasswordResetService;
use App\Shared\Database\Migrator;
use App\Shared\Mail\MailServiceInterface;
use App\User\User;
use App\User\UserRepository;
use PDO;
use PHPUnit\Framework\TestCase;
final class PasswordResetServiceIntegrationTest extends TestCase
{
private PDO $db;
private PasswordResetService $service;
private UserRepository $users;
private PasswordResetRepository $resets;
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
Migrator::run($this->db);
$this->users = new UserRepository($this->db);
$this->resets = new PasswordResetRepository($this->db);
$mail = new class implements MailServiceInterface {
public function send(string $to, string $subject, string $template, array $context = []): void
{
}
};
$this->service = new PasswordResetService($this->resets, $this->users, $mail, $this->db);
}
public function testResetPasswordConsumesTokenOnlyOnceAndUpdatesPassword(): void
{
$userId = $this->users->create(new User(0, 'alice', 'alice@example.com', password_hash('ancienpass1', PASSWORD_BCRYPT)));
$tokenRaw = 'token-brut-integration';
$tokenHash = hash('sha256', $tokenRaw);
$this->resets->create($userId, $tokenHash, date('Y-m-d H:i:s', time() + 3600));
$this->service->resetPassword($tokenRaw, 'nouveaupass1');
$user = $this->users->findById($userId);
self::assertNotNull($user);
self::assertTrue(password_verify('nouveaupass1', $user->getPasswordHash()));
$row = $this->db->query("SELECT used_at FROM password_resets WHERE token_hash = '{$tokenHash}'")->fetch();
self::assertIsArray($row);
self::assertNotEmpty($row['used_at']);
$this->expectException(InvalidResetTokenException::class);
$this->service->resetPassword($tokenRaw, 'encoreplusfort1');
}
}

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetRepositoryInterface;
use App\Auth\PasswordResetService;
use App\Shared\Mail\MailServiceInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use PDO;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PasswordResetService.
*
* Vérifie la génération de token, la validation et la réinitialisation
* du mot de passe. Les dépendances sont mockées via leurs interfaces.
*/
final class PasswordResetServiceTest extends TestCase
{
/** @var PasswordResetRepositoryInterface&MockObject */
private PasswordResetRepositoryInterface $resetRepository;
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
/** @var MailServiceInterface&MockObject */
private MailServiceInterface $mailService;
private PasswordResetService $service;
/** @var PDO&MockObject */
private PDO $db;
protected function setUp(): void
{
$this->resetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->mailService = $this->createMock(MailServiceInterface::class);
$this->db = $this->createMock(PDO::class);
$this->service = new PasswordResetService(
$this->resetRepository,
$this->userRepository,
$this->mailService,
$this->db,
);
}
// ── requestReset ───────────────────────────────────────────────
/**
* requestReset() avec un email inconnu ne doit ni envoyer d'email
* ni lever d'exception (protection contre l'énumération d'emails).
*/
public function testRequestResetUnknownEmailReturnsSilently(): void
{
$this->userRepository->method('findByEmail')->willReturn(null);
$this->mailService->expects($this->never())->method('send');
$this->resetRepository->expects($this->never())->method('create');
$this->service->requestReset('inconnu@example.com', 'https://blog.exemple.com');
}
/**
* requestReset() doit invalider les tokens précédents avant d'en créer un nouveau.
*/
public function testRequestResetInvalidatesPreviousTokens(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->expects($this->once())
->method('invalidateByUserId')
->with($user->getId());
$this->resetRepository->method('create');
$this->mailService->method('send');
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
}
/**
* requestReset() doit persister un nouveau token en base.
*/
public function testRequestResetCreatesTokenInDatabase(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create')
->with($user->getId(), $this->isType('string'), $this->isType('string'));
$this->mailService->method('send');
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
}
/**
* requestReset() doit envoyer un email avec le bon destinataire et template.
*/
public function testRequestResetSendsEmailWithCorrectAddress(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId');
$this->resetRepository->method('create');
$this->mailService->expects($this->once())
->method('send')
->with(
'alice@example.com',
$this->isType('string'),
'emails/password-reset.twig',
$this->isType('array'),
);
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
}
/**
* L'URL de réinitialisation dans le contexte de l'email doit contenir le token brut.
*/
public function testRequestResetUrlContainsToken(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId');
$this->resetRepository->method('create');
$this->mailService->expects($this->once())
->method('send')
->with(
$this->anything(),
$this->anything(),
$this->anything(),
$this->callback(function (array $context): bool {
return isset($context['resetUrl'])
&& str_contains($context['resetUrl'], '/password/reset?token=');
}),
);
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
}
// ── validateToken ──────────────────────────────────────────────
/**
* validateToken() avec un token inexistant doit retourner null.
*/
public function testValidateTokenMissingToken(): void
{
$this->resetRepository->method('findActiveByHash')->willReturn(null);
$result = $this->service->validateToken('tokeninexistant');
$this->assertNull($result);
}
/**
* validateToken() avec un token expiré doit retourner null.
*/
public function testValidateTokenExpiredToken(): void
{
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->method('findActiveByHash')->with($tokenHash)->willReturn([
'user_id' => 1,
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() - 3600),
'used_at' => null,
]);
$result = $this->service->validateToken($tokenRaw);
$this->assertNull($result);
}
/**
* validateToken() avec un token valide doit retourner l'utilisateur associé.
*/
public function testValidateTokenValidToken(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->method('findActiveByHash')->with($tokenHash)->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->method('findById')->with($user->getId())->willReturn($user);
$result = $this->service->validateToken($tokenRaw);
$this->assertSame($user, $result);
}
// ── resetPassword ──────────────────────────────────────────────
/**
* resetPassword() avec un token invalide doit lever une InvalidArgumentException.
*/
/**
* validateToken() doit retourner null si le token est valide mais l'utilisateur a été supprimé.
*/
public function testValidateTokenDeletedUserReturnsNull(): void
{
$row = [
'user_id' => 999,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
];
$this->resetRepository->method('findActiveByHash')->willReturn($row);
$this->userRepository->method('findById')->with(999)->willReturn(null);
$result = $this->service->validateToken('token-valide-mais-user-supprime');
$this->assertNull($result);
}
public function testResetPasswordInvalidToken(): void
{
$this->db->method('beginTransaction')->willReturn(true);
$this->db->method('inTransaction')->willReturn(true);
$this->db->expects($this->once())->method('rollBack');
$this->resetRepository->method('consumeActiveToken')->willReturn(null);
$this->expectException(InvalidResetTokenException::class);
$this->expectExceptionMessageMatches('/invalide ou a expiré/');
$this->service->resetPassword('tokeninvalide', 'nouveaumdp1');
}
/**
* resetPassword() avec un mot de passe trop court doit lever WeakPasswordException.
*/
public function testResetPasswordTooShortPasswordThrowsWeakPasswordException(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->method('findActiveByHash')->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->method('findById')->willReturn($user);
$this->expectException(WeakPasswordException::class);
$this->service->resetPassword($tokenRaw, '1234567');
}
/**
* resetPassword() doit mettre à jour le mot de passe et marquer le token comme consommé.
*/
public function testResetPasswordUpdatesPasswordAndConsumesToken(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->db->method('beginTransaction')->willReturn(true);
$this->db->method('inTransaction')->willReturn(true);
$this->db->expects($this->once())->method('commit');
$this->resetRepository->expects($this->once())
->method('consumeActiveToken')
->with($tokenHash, $this->isType('string'))
->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->method('findById')->willReturn($user);
$this->userRepository->expects($this->once())
->method('updatePassword')
->with($user->getId(), $this->callback('is_string'));
$this->service->resetPassword($tokenRaw, 'nouveaumdp1');
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un utilisateur de test standard.
*/
private function makeUser(): User
{
return new User(
1,
'alice',
'alice@example.com',
password_hash('motdepasse1', PASSWORD_BCRYPT),
);
}
}

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use App\Category\CategoryController;
use App\Category\CategoryServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour CategoryController.
*
* Couvre index(), create() et delete() :
* rendu de la liste, création réussie, erreur de création,
* suppression avec catégorie introuvable, succès et erreur métier.
*/
final class CategoryControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var CategoryServiceInterface&MockObject */
private CategoryServiceInterface $categoryService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
private CategoryController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->categoryService = $this->createMock(CategoryServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->controller = new CategoryController(
$this->view,
$this->categoryService,
$this->flash,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit rendre la vue avec la liste des catégories.
*/
public function testIndexRendersWithCategories(): void
{
$this->categoryService->method('findAll')->willReturn([]);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/categories/index.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->index($this->makeGet('/admin/categories'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── create ───────────────────────────────────────────────────────
/**
* create() doit flasher un succès et rediriger en cas de création réussie.
*/
public function testCreateRedirectsWithSuccessFlash(): void
{
$this->categoryService->method('create')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('category_success', $this->stringContains('PHP'));
$req = $this->makePost('/admin/categories/create', ['name' => 'PHP']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* create() doit flasher une erreur si le service lève une InvalidArgumentException.
*/
public function testCreateRedirectsWithErrorOnInvalidArgument(): void
{
$this->categoryService->method('create')
->willThrowException(new \InvalidArgumentException('Catégorie déjà existante'));
$this->flash->expects($this->once())->method('set')
->with('category_error', 'Catégorie déjà existante');
$req = $this->makePost('/admin/categories/create', ['name' => 'Duplicate']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* create() doit flasher une erreur générique pour toute autre exception.
*/
public function testCreateRedirectsWithGenericErrorOnUnexpectedException(): void
{
$this->categoryService->method('create')
->willThrowException(new \RuntimeException('DB error'));
$this->flash->expects($this->once())->method('set')
->with('category_error', $this->stringContains('inattendue'));
$req = $this->makePost('/admin/categories/create', ['name' => 'PHP']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/categories');
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit flasher une erreur et rediriger si la catégorie est introuvable.
*/
public function testDeleteRedirectsWithErrorWhenNotFound(): void
{
$this->categoryService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('category_error', 'Catégorie introuvable');
$res = $this->controller->delete(
$this->makePost('/admin/categories/delete/99'),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* delete() doit flasher un succès et rediriger en cas de suppression réussie.
*/
public function testDeleteRedirectsWithSuccessFlash(): void
{
$category = new Category(3, 'PHP', 'php');
$this->categoryService->method('findById')->willReturn($category);
$this->flash->expects($this->once())->method('set')
->with('category_success', $this->stringContains('PHP'));
$res = $this->controller->delete(
$this->makePost('/admin/categories/delete/3'),
$this->makeResponse(),
['id' => '3'],
);
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* delete() doit flasher une erreur si le service refuse la suppression
* (ex: des articles sont rattachés à la catégorie).
*/
public function testDeleteRedirectsWithErrorWhenServiceRefuses(): void
{
$category = new Category(3, 'PHP', 'php');
$this->categoryService->method('findById')->willReturn($category);
$this->categoryService->method('delete')
->willThrowException(new \InvalidArgumentException('Des articles utilisent cette catégorie'));
$this->flash->expects($this->once())->method('set')
->with('category_error', 'Des articles utilisent cette catégorie');
$res = $this->controller->delete(
$this->makePost('/admin/categories/delete/3'),
$this->makeResponse(),
['id' => '3'],
);
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* delete() doit passer l'identifiant de route au service findById().
*/
public function testDeletePassesCorrectIdToService(): void
{
$this->categoryService->expects($this->once())
->method('findById')
->with(7)
->willReturn(null);
$this->flash->method('set');
$this->controller->delete(
$this->makePost('/admin/categories/delete/7'),
$this->makeResponse(),
['id' => '7'],
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use PHPUnit\Framework\TestCase;
final class CategoryModelTest extends TestCase
{
public function testConstructAndGettersExposeCategoryData(): void
{
$category = new Category(4, 'PHP', 'php');
self::assertSame(4, $category->getId());
self::assertSame('PHP', $category->getName());
self::assertSame('php', $category->getSlug());
}
public function testFromArrayHydratesCategory(): void
{
$category = Category::fromArray([
'id' => '6',
'name' => 'Tests',
'slug' => 'tests',
]);
self::assertSame(6, $category->getId());
self::assertSame('Tests', $category->getName());
self::assertSame('tests', $category->getSlug());
}
public function testValidationRejectsEmptyName(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le nom de la catégorie ne peut pas être vide');
new Category(1, '', 'slug');
}
public function testValidationRejectsTooLongName(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le nom de la catégorie ne peut pas dépasser 100 caractères');
new Category(1, str_repeat('a', 101), 'slug');
}
}

View File

@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use App\Category\CategoryRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour CategoryRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
final class CategoryRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private CategoryRepository $repository;
/**
* Données représentant une ligne catégorie en base de données.
*
* @var array<string, mixed>
*/
private array $rowPhp;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new CategoryRepository($this->db);
$this->rowPhp = [
'id' => 1,
'name' => 'PHP',
'slug' => 'php',
];
}
// ── Helpers ────────────────────────────────────────────────────
private function stmtForRead(array $rows = [], array|false $row = false): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchAll')->willReturn($rows);
$stmt->method('fetch')->willReturn($row);
return $stmt;
}
private function stmtForScalar(mixed $value): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn($value);
return $stmt;
}
private function stmtForWrite(int $rowCount = 1): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('rowCount')->willReturn($rowCount);
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() retourne un tableau vide si aucune catégorie n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() retourne des instances Category hydratées.
*/
public function testFindAllReturnsCategoryInstances(): void
{
$stmt = $this->stmtForRead([$this->rowPhp]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(Category::class, $result[0]);
$this->assertSame('PHP', $result[0]->getName());
$this->assertSame('php', $result[0]->getSlug());
}
/**
* findAll() interroge la table 'categories' triée par name ASC.
*/
public function testFindAllQueriesWithAlphabeticOrder(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('categories'),
$this->stringContains('name ASC'),
))
->willReturn($stmt);
$this->repository->findAll();
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si la catégorie est absente.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}
/**
* findById() retourne une instance Category si la catégorie existe.
*/
public function testFindByIdReturnsCategoryWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowPhp);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(Category::class, $result);
$this->assertSame(1, $result->getId());
$this->assertSame('PHP', $result->getName());
}
/**
* findById() exécute avec le bon identifiant.
*/
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 42]);
$this->repository->findById(42);
}
// ── findBySlug ─────────────────────────────────────────────────
/**
* findBySlug() retourne null si le slug est absent.
*/
public function testFindBySlugReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findBySlug('inconnu'));
}
/**
* findBySlug() retourne une instance Category si le slug existe.
*/
public function testFindBySlugReturnsCategoryWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowPhp);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findBySlug('php');
$this->assertInstanceOf(Category::class, $result);
$this->assertSame('php', $result->getSlug());
}
/**
* findBySlug() exécute avec le bon slug.
*/
public function testFindBySlugQueriesWithCorrectSlug(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':slug' => 'php']);
$this->repository->findBySlug('php');
}
// ── create ─────────────────────────────────────────────────────
/**
* create() prépare un INSERT avec le nom et le slug de la catégorie.
*/
public function testCreateCallsInsertWithNameAndSlug(): void
{
$category = Category::fromArray($this->rowPhp);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('INSERT INTO categories'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $data): bool =>
$data[':name'] === $category->getName()
&& $data[':slug'] === $category->getSlug()
));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($category);
}
/**
* create() retourne l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$category = Category::fromArray($this->rowPhp);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('7');
$this->assertSame(7, $this->repository->create($category));
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() prépare un DELETE avec le bon identifiant.
*/
public function testDeleteCallsDeleteWithCorrectId(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM categories'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 3]);
$this->repository->delete(3);
}
/**
* delete() retourne le nombre de lignes supprimées.
*/
public function testDeleteReturnsDeletedRowCount(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(1, $this->repository->delete(3));
}
/**
* delete() retourne 0 si la catégorie n'existait plus.
*/
public function testDeleteReturnsZeroWhenNotFound(): void
{
$stmt = $this->stmtForWrite(0);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(0, $this->repository->delete(99));
}
// ── nameExists ─────────────────────────────────────────────────
/**
* nameExists() retourne true si le nom existe déjà.
*/
public function testNameExistsReturnsTrueWhenTaken(): void
{
$stmt = $this->stmtForScalar(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertTrue($this->repository->nameExists('PHP'));
}
/**
* nameExists() retourne false si le nom est disponible.
*/
public function testNameExistsReturnsFalseWhenFree(): void
{
$stmt = $this->stmtForScalar(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertFalse($this->repository->nameExists('Nouveau'));
}
/**
* nameExists() exécute avec le bon nom.
*/
public function testNameExistsQueriesWithCorrectName(): void
{
$stmt = $this->stmtForScalar(false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':name' => 'PHP']);
$this->repository->nameExists('PHP');
}
// ── hasPost ────────────────────────────────────────────────────
/**
* hasPost() retourne true si au moins un article référence la catégorie.
*/
public function testHasPostReturnsTrueWhenPostAttached(): void
{
$stmt = $this->stmtForScalar(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertTrue($this->repository->hasPost(1));
}
/**
* hasPost() retourne false si aucun article ne référence la catégorie.
*/
public function testHasPostReturnsFalseWhenNoPost(): void
{
$stmt = $this->stmtForScalar(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertFalse($this->repository->hasPost(1));
}
/**
* hasPost() interroge la table 'posts' avec le bon category_id.
*/
public function testHasPostQueriesPostsTableWithCorrectId(): void
{
$stmt = $this->stmtForScalar(false);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 5]);
$this->repository->hasPost(5);
}
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use App\Category\CategoryRepositoryInterface;
use App\Category\CategoryService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour CategoryService.
*
* Vérifie la création (génération de slug, unicité du nom, validation du modèle)
* et la suppression (blocage si articles rattachés).
* Le repository est remplacé par un mock pour isoler le service.
*/
final class CategoryServiceTest extends TestCase
{
/** @var CategoryRepositoryInterface&MockObject */
private CategoryRepositoryInterface $repository;
private CategoryService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(CategoryRepositoryInterface::class);
$this->service = new CategoryService($this->repository);
}
// ── create ─────────────────────────────────────────────────────
/**
* create() doit générer le slug depuis le nom et persister la catégorie.
*/
public function testCreateGeneratesSlugAndPersists(): void
{
$this->repository->method('nameExists')->willReturn(false);
$this->repository->expects($this->once())
->method('create')
->with($this->callback(fn (Category $c) =>
$c->getName() === 'Développement web'
&& $c->getSlug() === 'developpement-web'
))
->willReturn(1);
$id = $this->service->create('Développement web');
$this->assertSame(1, $id);
}
/**
* create() doit trimmer le nom avant de générer le slug.
*/
public function testCreateTrimsName(): void
{
$this->repository->method('nameExists')->willReturn(false);
$this->repository->expects($this->once())
->method('create')
->with($this->callback(fn (Category $c) => $c->getName() === 'PHP'))
->willReturn(2);
$this->service->create(' PHP ');
}
/**
* create() doit lever InvalidArgumentException si le slug généré est vide.
*/
public function testCreateNonAsciiNameThrowsException(): void
{
$this->repository->expects($this->never())->method('create');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('slug URL valide');
$this->service->create('日本語');
}
/**
* create() doit lever InvalidArgumentException si le nom est déjà utilisé.
*/
public function testCreateDuplicateNameThrowsException(): void
{
$this->repository->method('nameExists')->willReturn(true);
$this->repository->expects($this->never())->method('create');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('déjà utilisé');
$this->service->create('PHP');
}
/**
* create() doit lever InvalidArgumentException si le nom est vide.
*/
public function testCreateEmptyNameThrowsException(): void
{
$this->repository->method('nameExists')->willReturn(false);
$this->repository->expects($this->never())->method('create');
$this->expectException(\InvalidArgumentException::class);
$this->service->create('');
}
/**
* create() doit lever InvalidArgumentException si le nom dépasse 100 caractères.
*/
public function testCreateNameTooLongThrowsException(): void
{
$longName = str_repeat('a', 101);
$this->repository->method('nameExists')->willReturn(false);
$this->repository->expects($this->never())->method('create');
$this->expectException(\InvalidArgumentException::class);
$this->service->create($longName);
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() doit supprimer la catégorie si elle ne contient aucun article.
*/
public function testDeleteSucceedsWhenNoPosts(): void
{
$category = new Category(5, 'PHP', 'php');
$this->repository->method('hasPost')->with(5)->willReturn(false);
$this->repository->expects($this->once())->method('delete')->with(5);
$this->service->delete($category);
}
/**
* delete() doit lever InvalidArgumentException si des articles sont rattachés.
*/
public function testDeleteBlockedWhenPostsAttached(): void
{
$category = new Category(5, 'PHP', 'php');
$this->repository->method('hasPost')->with(5)->willReturn(true);
$this->repository->expects($this->never())->method('delete');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('contient des articles');
$this->service->delete($category);
}
// ── Lectures déléguées ─────────────────────────────────────────
/**
* findAll() doit déléguer au repository et retourner son résultat.
*/
public function testFindAllDelegatesToRepository(): void
{
$cats = [new Category(1, 'PHP', 'php'), new Category(2, 'CSS', 'css')];
$this->repository->method('findAll')->willReturn($cats);
$this->assertSame($cats, $this->service->findAll());
}
/**
* findById() doit retourner null si la catégorie n'existe pas.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$this->repository->method('findById')->willReturn(null);
$this->assertNull($this->service->findById(99));
}
/**
* findBySlug() doit retourner la catégorie correspondante.
*/
public function testFindBySlugReturnsCategoryWhenFound(): void
{
$cat = new Category(3, 'PHP', 'php');
$this->repository->method('findBySlug')->with('php')->willReturn($cat);
$this->assertSame($cat, $this->service->findBySlug('php'));
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response as SlimResponse;
/**
* Classe de base pour les tests de contrôleurs.
*
* Fournit des helpers pour construire des requêtes PSR-7 sans serveur,
* des assertions sur les redirections et les réponses JSON,
* ainsi qu'un stub de Twig qui retourne la réponse inchangée.
*
* Chaque test de contrôleur invoque directement l'action (méthode publique)
* sans passer par le routeur Slim — les middlewares sont testés séparément.
*/
abstract class ControllerTestCase extends TestCase
{
// ── Factories ────────────────────────────────────────────────────
/**
* Crée une requête GET avec des paramètres de query optionnels.
*
* @param string $uri URI de la requête (ex: '/admin/posts')
* @param array<string, mixed> $queryParams Paramètres de query string
* @param array<string, mixed> $serverParams Paramètres serveur (ex: REMOTE_ADDR)
*/
protected function makeGet(
string $uri = '/',
array $queryParams = [],
array $serverParams = [],
): \Psr\Http\Message\ServerRequestInterface {
$factory = new ServerRequestFactory();
$request = $factory->createServerRequest('GET', $uri, $serverParams);
if ($queryParams !== []) {
$request = $request->withQueryParams($queryParams);
}
return $request;
}
/**
* Crée une requête POST avec un corps parsé optionnel.
*
* @param string $uri URI de la requête (ex: '/auth/login')
* @param array<string, mixed> $body Corps de la requête (form data)
* @param array<string, mixed> $serverParams Paramètres serveur (ex: REMOTE_ADDR)
*/
protected function makePost(
string $uri = '/',
array $body = [],
array $serverParams = [],
): \Psr\Http\Message\ServerRequestInterface {
$factory = new ServerRequestFactory();
$request = $factory->createServerRequest('POST', $uri, $serverParams);
if ($body !== []) {
$request = $request->withParsedBody($body);
}
return $request;
}
/**
* Crée une réponse PSR-7 vide (status 200).
*/
protected function makeResponse(): SlimResponse
{
return new SlimResponse();
}
/**
* Crée un mock de Twig dont render() retourne la réponse reçue en premier argument.
*
* Cela permet aux tests de contrôleurs qui appellent $this->view->render()
* de recevoir une réponse 200 sans instancier un environnement Twig réel.
*
* @return \Slim\Views\Twig&\PHPUnit\Framework\MockObject\MockObject
*/
protected function makeTwigMock(): \Slim\Views\Twig
{
$twig = $this->createMock(\Slim\Views\Twig::class);
$twig->method('render')->willReturnArgument(0);
return $twig;
}
// ── Assertions ───────────────────────────────────────────────────
/**
* Vérifie qu'une réponse est une redirection vers l'URL attendue.
*/
protected function assertRedirectTo(Response $res, string $expectedLocation): void
{
$this->assertSame(302, $res->getStatusCode(), 'Le code HTTP devrait être 302');
$this->assertSame(
$expectedLocation,
$res->getHeaderLine('Location'),
"La redirection devrait pointer vers {$expectedLocation}",
);
}
/**
* Vérifie le code HTTP d'une réponse.
*/
protected function assertStatus(Response $res, int $expectedStatus): void
{
$this->assertSame($expectedStatus, $res->getStatusCode());
}
/**
* Vérifie que le corps de la réponse est du JSON contenant les clés attendues.
*
* @param array<string, mixed> $expectedSubset Sous-ensemble de clés/valeurs attendues
*/
protected function assertJsonContains(Response $res, array $expectedSubset): void
{
$body = (string) $res->getBody();
$decoded = json_decode($body, true);
$this->assertIsArray($decoded, 'Le corps de la réponse devrait être du JSON valide');
foreach ($expectedSubset as $key => $value) {
$this->assertArrayHasKey($key, $decoded, "La clé JSON '{$key}' devrait être présente");
$this->assertSame($value, $decoded[$key], "La valeur JSON de '{$key}' est incorrecte");
}
}
/**
* Vérifie que le Content-Type de la réponse est application/json.
*/
protected function assertJsonContentType(Response $res): void
{
$this->assertStringContainsString(
'application/json',
$res->getHeaderLine('Content-Type'),
);
}
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Exception\StorageException;
use App\Media\Media;
use App\Media\MediaController;
use App\Media\MediaServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Message\UploadedFileInterface;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour MediaController.
*
* Couvre index(), upload() et delete() :
* - index : filtrage admin vs utilisateur ordinaire
* - upload : absence de fichier, erreur PSR-7, exceptions métier (taille, MIME, stockage), succès
* - delete : introuvable, non-propriétaire, succès propriétaire, succès admin
*/
final class MediaControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var MediaServiceInterface&MockObject */
private MediaServiceInterface $mediaService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private MediaController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->mediaService = $this->createMock(MediaServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->controller = new MediaController(
$this->view,
$this->mediaService,
$this->flash,
$this->sessionManager,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit appeler findAll() pour un admin et rendre la vue.
*/
public function testIndexShowsAllMediaForAdmin(): void
{
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->mediaService->expects($this->once())->method('findAll')->willReturn([]);
$this->mediaService->expects($this->never())->method('findByUserId');
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/media/index.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* index() doit appeler findAll() pour un éditeur.
*/
public function testIndexShowsAllMediaForEditor(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(true);
$this->mediaService->expects($this->once())->method('findAll')->willReturn([]);
$this->mediaService->expects($this->never())->method('findByUserId');
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* index() doit appeler findByUserId() pour un utilisateur ordinaire.
*/
public function testIndexShowsOwnMediaForRegularUser(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(42);
$this->mediaService->expects($this->once())->method('findByUserId')->with(42)->willReturn([]);
$this->mediaService->expects($this->never())->method('findAll');
$this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
}
// ── upload ───────────────────────────────────────────────────────
/**
* upload() doit retourner 400 JSON si aucun fichier n'est dans la requête.
*/
public function testUploadReturns400WhenNoFilePresent(): void
{
$req = $this->makePost('/admin/media/upload');
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 400);
$this->assertJsonContentType($res);
$this->assertJsonContains($res, ['error' => "Aucun fichier reçu ou erreur d'upload"]);
}
/**
* upload() doit retourner 400 JSON si le fichier PSR-7 signale une erreur d'upload.
*/
public function testUploadReturns400WhenFileHasUploadError(): void
{
/** @var UploadedFileInterface&MockObject $file */
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getError')->willReturn(UPLOAD_ERR_INI_SIZE);
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 400);
$this->assertJsonContains($res, ['error' => "Aucun fichier reçu ou erreur d'upload"]);
}
/**
* upload() doit retourner 413 JSON si le fichier dépasse la taille autorisée.
*/
public function testUploadReturns413OnFileTooLarge(): void
{
$file = $this->makeValidUploadedFile();
$this->mediaService->method('store')
->willThrowException(new FileTooLargeException(2 * 1024 * 1024));
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 413);
$this->assertJsonContentType($res);
}
/**
* upload() doit retourner 415 JSON si le type MIME n'est pas autorisé.
*/
public function testUploadReturns415OnInvalidMimeType(): void
{
$file = $this->makeValidUploadedFile();
$this->mediaService->method('store')
->willThrowException(new InvalidMimeTypeException('application/pdf'));
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 415);
$this->assertJsonContentType($res);
}
/**
* upload() doit retourner 500 JSON si une erreur de stockage survient.
*/
public function testUploadReturns500OnStorageException(): void
{
$file = $this->makeValidUploadedFile();
$this->mediaService->method('store')
->willThrowException(new StorageException('Disk full'));
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 500);
$this->assertJsonContentType($res);
}
/**
* upload() doit retourner 200 JSON avec l'URL du fichier en cas de succès.
*/
public function testUploadReturns200JsonWithUrlOnSuccess(): void
{
$file = $this->makeValidUploadedFile();
$this->sessionManager->method('getUserId')->willReturn(1);
$this->mediaService->method('store')->willReturn('/media/abc123.webp');
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 200);
$this->assertJsonContentType($res);
$this->assertJsonContains($res, ['success' => true, 'file' => '/media/abc123.webp']);
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit flasher une erreur et rediriger si le média est introuvable.
*/
public function testDeleteRedirectsWithErrorWhenMediaNotFound(): void
{
$this->mediaService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('media_error', 'Fichier introuvable');
$res = $this->controller->delete(
$this->makePost('/admin/media/delete/99'),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/media');
}
/**
* delete() doit flasher une erreur si l'utilisateur n'est pas propriétaire du média.
*/
public function testDeleteRedirectsWithErrorWhenNotOwner(): void
{
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 10);
$this->mediaService->method('findById')->willReturn($media);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(99); // autre utilisateur
$this->flash->expects($this->once())->method('set')
->with('media_error', $this->stringContains('autorisé'));
$res = $this->controller->delete(
$this->makePost('/admin/media/delete/5'),
$this->makeResponse(),
['id' => '5'],
);
$this->assertRedirectTo($res, '/admin/media');
}
/**
* delete() doit supprimer le média et rediriger avec succès si l'utilisateur est propriétaire.
*/
public function testDeleteSucceedsForOwner(): void
{
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42);
$this->mediaService->method('findById')->willReturn($media);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(42);
$this->mediaService->expects($this->once())->method('delete')->with($media);
$this->flash->expects($this->once())->method('set')
->with('media_success', 'Fichier supprimé');
$res = $this->controller->delete(
$this->makePost('/admin/media/delete/5'),
$this->makeResponse(),
['id' => '5'],
);
$this->assertRedirectTo($res, '/admin/media');
}
/**
* delete() doit permettre la suppression à un admin même s'il n'est pas propriétaire.
*/
public function testDeleteSucceedsForAdmin(): void
{
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 10);
$this->mediaService->method('findById')->willReturn($media);
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(1); // admin, pas propriétaire
$this->mediaService->expects($this->once())->method('delete');
$res = $this->controller->delete(
$this->makePost('/admin/media/delete/5'),
$this->makeResponse(),
['id' => '5'],
);
$this->assertRedirectTo($res, '/admin/media');
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Crée un mock d'UploadedFileInterface sans erreur d'upload.
*
* @return UploadedFileInterface&MockObject
*/
private function makeValidUploadedFile(): UploadedFileInterface
{
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getError')->willReturn(UPLOAD_ERR_OK);
return $file;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Media;
use DateTime;
use PHPUnit\Framework\TestCase;
final class MediaModelTest extends TestCase
{
public function testConstructAndGettersExposeMediaData(): void
{
$createdAt = new DateTime('2026-03-01 10:00:00');
$media = new Media(8, 'file.webp', '/media/file.webp', 'abc123', 12, $createdAt);
self::assertSame(8, $media->getId());
self::assertSame('file.webp', $media->getFilename());
self::assertSame('/media/file.webp', $media->getUrl());
self::assertSame('abc123', $media->getHash());
self::assertSame(12, $media->getUserId());
self::assertSame($createdAt, $media->getCreatedAt());
}
public function testFromArrayHydratesMedia(): void
{
$media = Media::fromArray([
'id' => '9',
'filename' => 'stored.webp',
'url' => '/media/stored.webp',
'hash' => 'hash-9',
'user_id' => '3',
'created_at' => '2026-03-02 12:30:00',
]);
self::assertSame(9, $media->getId());
self::assertSame('stored.webp', $media->getFilename());
self::assertSame('/media/stored.webp', $media->getUrl());
self::assertSame('hash-9', $media->getHash());
self::assertSame(3, $media->getUserId());
self::assertSame('2026-03-02 12:30:00', $media->getCreatedAt()->format('Y-m-d H:i:s'));
}
public function testCreatedAtDefaultsToNowWhenMissing(): void
{
$before = new DateTime('-2 seconds');
$media = new Media(1, 'f.webp', '/media/f.webp', 'h', null);
$after = new DateTime('+2 seconds');
self::assertGreaterThanOrEqual($before->getTimestamp(), $media->getCreatedAt()->getTimestamp());
self::assertLessThanOrEqual($after->getTimestamp(), $media->getCreatedAt()->getTimestamp());
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Media;
use App\Media\MediaRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour MediaRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
final class MediaRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private MediaRepository $repository;
/**
* Données représentant une ligne média en base de données.
*
* @var array<string, mixed>
*/
private array $rowImage;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new MediaRepository($this->db);
$this->rowImage = [
'id' => 1,
'filename' => 'photo.webp',
'url' => '/media/photo.webp',
'hash' => str_repeat('a', 64),
'user_id' => 2,
'created_at' => '2024-06-01 10:00:00',
];
}
// ── Helpers ────────────────────────────────────────────────────
private function stmtForRead(array $rows = [], array|false $row = false): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchAll')->willReturn($rows);
$stmt->method('fetch')->willReturn($row);
return $stmt;
}
private function stmtForWrite(int $rowCount = 1): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('rowCount')->willReturn($rowCount);
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() retourne un tableau vide si aucun média n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() retourne des instances Media hydratées.
*/
public function testFindAllReturnsMediaInstances(): void
{
$stmt = $this->stmtForRead([$this->rowImage]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(Media::class, $result[0]);
$this->assertSame('photo.webp', $result[0]->getFilename());
$this->assertSame('/media/photo.webp', $result[0]->getUrl());
}
/**
* findAll() interroge la table 'media' triée par id DESC.
*/
public function testFindAllQueriesWithDescendingOrder(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('media'),
$this->stringContains('id DESC'),
))
->willReturn($stmt);
$this->repository->findAll();
}
// ── findByUserId ───────────────────────────────────────────────
/**
* findByUserId() retourne un tableau vide si l'utilisateur n'a aucun média.
*/
public function testFindByUserIdReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findByUserId(99));
}
/**
* findByUserId() retourne uniquement les médias de l'utilisateur donné.
*/
public function testFindByUserIdReturnsUserMedia(): void
{
$stmt = $this->stmtForRead([$this->rowImage]);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByUserId(2);
$this->assertCount(1, $result);
$this->assertSame(2, $result[0]->getUserId());
}
/**
* findByUserId() exécute avec le bon user_id.
*/
public function testFindByUserIdQueriesWithCorrectUserId(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':user_id' => 5]);
$this->repository->findByUserId(5);
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si le média est absent.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}
/**
* findById() retourne une instance Media si le média existe.
*/
public function testFindByIdReturnsMediaWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowImage);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(Media::class, $result);
$this->assertSame(1, $result->getId());
}
/**
* findById() exécute avec le bon identifiant.
*/
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 8]);
$this->repository->findById(8);
}
// ── findByHash ─────────────────────────────────────────────────
/**
* findByHash() retourne null si aucun média ne correspond au hash.
*/
public function testFindByHashReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByHash(str_repeat('b', 64)));
}
/**
* findByHash() retourne une instance Media si le hash existe (doublon détecté).
*/
public function testFindByHashReturnsDuplicateMedia(): void
{
$stmt = $this->stmtForRead(row: $this->rowImage);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByHash(str_repeat('a', 64));
$this->assertInstanceOf(Media::class, $result);
$this->assertSame(str_repeat('a', 64), $result->getHash());
}
/**
* findByHash() exécute avec le bon hash.
*/
public function testFindByHashQueriesWithCorrectHash(): void
{
$hash = str_repeat('c', 64);
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':hash' => $hash]);
$this->repository->findByHash($hash);
}
// ── create ─────────────────────────────────────────────────────
/**
* create() prépare un INSERT avec les bonnes colonnes.
*/
public function testCreateCallsInsertWithCorrectData(): void
{
$media = Media::fromArray($this->rowImage);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('INSERT INTO media'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($media): bool {
return $data[':filename'] === $media->getFilename()
&& $data[':url'] === $media->getUrl()
&& $data[':hash'] === $media->getHash()
&& $data[':user_id'] === $media->getUserId()
&& isset($data[':created_at']);
}));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($media);
}
/**
* create() retourne l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$media = Media::fromArray($this->rowImage);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('15');
$this->assertSame(15, $this->repository->create($media));
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() prépare un DELETE avec le bon identifiant.
*/
public function testDeleteCallsDeleteWithCorrectId(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM media'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 4]);
$this->repository->delete(4);
}
/**
* delete() retourne le nombre de lignes supprimées.
*/
public function testDeleteReturnsDeletedRowCount(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(1, $this->repository->delete(4));
}
/**
* delete() retourne 0 si le média n'existait plus.
*/
public function testDeleteReturnsZeroWhenNotFound(): void
{
$stmt = $this->stmtForWrite(0);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(0, $this->repository->delete(99));
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Media;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use PDOException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
{
/** @var MediaRepositoryInterface&MockObject */
private MediaRepositoryInterface $repository;
private string $uploadDir;
private MediaService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(MediaRepositoryInterface::class);
$this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true);
@mkdir($this->uploadDir, 0755, true);
$this->service = new MediaService($this->repository, $this->uploadDir, '/media', 5 * 1024 * 1024);
}
protected function tearDown(): void
{
foreach (glob($this->uploadDir . '/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($this->uploadDir);
}
public function testReturnsDuplicateUrlWhenInsertRaceOccurs(): void
{
$tmpFile = $this->createMinimalGif();
$hash = hash_file('sha256', $tmpFile);
self::assertNotFalse($hash);
$duplicate = new Media(77, 'existing.gif', '/media/existing.gif', $hash, 1);
$this->repository->expects($this->exactly(2))
->method('findByHash')
->with($hash)
->willReturnOnConsecutiveCalls(null, $duplicate);
$this->repository->expects($this->once())
->method('create')
->willThrowException(new PDOException('duplicate key'));
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
$url = $this->service->store($file, 1);
self::assertSame('/media/existing.gif', $url);
self::assertCount(0, glob($this->uploadDir . '/*') ?: []);
@unlink($tmpFile);
}
private function makeUploadedFileFromPath(string $path, int $size): UploadedFileInterface
{
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn($path);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size);
$file->method('getStream')->willReturn($stream);
$file->method('moveTo')->willReturnCallback(static function (string $dest) use ($path): void {
copy($path, $dest);
});
return $file;
}
private function createMinimalGif(): string
{
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_gif_');
self::assertNotFalse($tmpFile);
file_put_contents($tmpFile, base64_decode('R0lGODdhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='));
return $tmpFile;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\MediaService;
use App\Media\MediaRepositoryInterface;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\StorageException;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\StreamInterface;
final class MediaServiceEdgeCasesTest extends TestCase
{
public function testRejectsWhenSizeUnknown(): void
{
$repo = $this->createMock(MediaRepositoryInterface::class);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(null);
$service = new MediaService($repo, '/tmp', '/media', 1000);
$this->expectException(StorageException::class);
$service->store($file, 1);
}
public function testRejectsWhenFileTooLarge(): void
{
$repo = $this->createMock(MediaRepositoryInterface::class);
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->willReturn('/tmp/file');
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(999999);
$file->method('getStream')->willReturn($stream);
$service = new MediaService($repo, '/tmp', '/media', 100);
$this->expectException(FileTooLargeException::class);
$service->store($file, 1);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\StreamInterface;
final class MediaServiceInvalidMimeTest extends TestCase
{
public function testRejectsNonImageContentEvenWithImageLikeFilename(): void
{
$repo = $this->createMock(MediaRepositoryInterface::class);
$tmpFile = tempnam(sys_get_temp_dir(), 'upload_');
self::assertNotFalse($tmpFile);
file_put_contents($tmpFile, 'not an image');
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn($tmpFile);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(filesize($tmpFile));
$file->method('getStream')->willReturn($stream);
$file->method('getClientFilename')->willReturn('photo.png');
$service = new MediaService($repo, sys_get_temp_dir(), '/media', 500000);
try {
$this->expectException(InvalidMimeTypeException::class);
$service->store($file, 1);
} finally {
@unlink($tmpFile);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Exception\StorageException;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
final class MediaServiceInvalidTempPathTest extends TestCase
{
public function testRejectsWhenTemporaryPathIsMissing(): void
{
$repository = $this->createMock(MediaRepositoryInterface::class);
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn(null);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(128);
$file->method('getStream')->willReturn($stream);
$service = new MediaService($repository, sys_get_temp_dir(), '/media', 500000);
$this->expectException(StorageException::class);
$this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé');
$service->store($file, 1);
}
}

View File

@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Media;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Tests unitaires pour MediaService.
*
* Stratégie : les opérations sur le système de fichiers réel (finfo, GD,
* copy, moveTo) sont exercées via de vrais fichiers JPEG temporaires ;
* le repository reste un mock pour isoler la logique de persistance.
*
* Les cas couverts :
* - rejet si taille > maxSize
* - rejet si type MIME non autorisé
* - déduplication : retour de l'URL existante si hash déjà connu
* - stockage : fichier écrit sur disque, media créé en base
* - suppression : fichier supprimé du disque et entrée retirée de la base
*/
final class MediaServiceTest extends TestCase
{
/** @var MediaRepositoryInterface&MockObject */
private MediaRepositoryInterface $repository;
private string $uploadDir;
private MediaService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(MediaRepositoryInterface::class);
$this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid();
@mkdir($this->uploadDir, 0755, true);
$this->service = new MediaService(
mediaRepository: $this->repository,
uploadDir: $this->uploadDir,
uploadUrl: '/media',
maxSize: 5 * 1024 * 1024,
);
}
protected function tearDown(): void
{
// Nettoyage du répertoire temporaire
foreach (glob($this->uploadDir . '/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($this->uploadDir);
}
// ── store — rejets ─────────────────────────────────────────────
/**
* store() doit lever FileTooLargeException si la taille dépasse le maximum.
*/
public function testStoreThrowsFileTooLargeWhenOversized(): void
{
$file = $this->makeUploadedFile(size: 6 * 1024 * 1024);
$this->expectException(FileTooLargeException::class);
$this->service->store($file, 1);
}
/**
* store() doit lever InvalidMimeTypeException pour un type MIME non autorisé.
*/
public function testStoreThrowsInvalidMimeType(): void
{
// Créer un vrai fichier texte — finfo retournera text/plain
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_test_');
file_put_contents($tmpFile, 'ceci est un fichier texte');
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
$this->expectException(InvalidMimeTypeException::class);
try {
$this->service->store($file, 1);
} finally {
@unlink($tmpFile);
}
}
// ── store — déduplication ──────────────────────────────────────
/**
* store() doit retourner l'URL existante sans créer de doublon si le hash est connu.
*/
public function testStoreReturnsDuplicateUrl(): void
{
$tmpFile = $this->createMinimalJpeg();
$hash = hash_file('sha256', $tmpFile);
$existing = new Media(7, 'existing.jpg', '/media/existing.jpg', $hash, 1);
$this->repository->method('findByHash')->willReturn($existing);
$this->repository->expects($this->never())->method('create');
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
$url = $this->service->store($file, 1);
$this->assertSame('/media/existing.jpg', $url);
@unlink($tmpFile);
}
// ── store — stockage nominal ───────────────────────────────────
/**
* store() doit créer le fichier sur disque et appeler repository::create().
*/
public function testStoreWritesFileAndCreatesRecord(): void
{
$tmpFile = $this->createMinimalJpeg();
$this->repository->method('findByHash')->willReturn(null);
$this->repository->expects($this->once())->method('create');
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
$url = $this->service->store($file, 1);
$this->assertStringStartsWith('/media/', $url);
// Le fichier doit exister sur le disque
$filename = basename($url);
$this->assertFileExists($this->uploadDir . DIRECTORY_SEPARATOR . $filename);
@unlink($tmpFile);
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() doit supprimer le fichier physique et appeler repository::delete().
*/
public function testDeleteRemovesFileAndRecord(): void
{
$filename = 'test_media.jpg';
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
file_put_contents($filePath, 'fake image data');
$media = new Media(42, $filename, '/media/' . $filename, 'fakehash', 1);
$this->repository->expects($this->once())->method('delete')->with(42);
$this->service->delete($media);
$this->assertFileDoesNotExist($filePath);
}
/**
* delete() ne doit pas lever d'exception si le fichier physique n'existe plus.
*/
public function testDeleteIsSilentWhenFileMissing(): void
{
$media = new Media(99, 'inexistant.jpg', '/media/inexistant.jpg', 'hash', 1);
$this->repository->expects($this->once())->method('delete')->with(99);
// Ne doit pas lever d'exception
$this->service->delete($media);
$this->assertTrue(true);
}
// ── Lectures déléguées ─────────────────────────────────────────
/**
* findById() doit déléguer au repository et retourner null si absent.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$this->repository->method('findById')->willReturn(null);
$this->assertNull($this->service->findById(999));
}
/**
* findById() doit retourner le média trouvé.
*/
public function testFindByIdReturnsMedia(): void
{
$media = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1);
$this->repository->method('findById')->with(3)->willReturn($media);
$this->assertSame($media, $this->service->findById(3));
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un mock d'UploadedFile avec une taille donnée et un tmpPath bidon.
*/
private function makeUploadedFile(int $size): UploadedFileInterface
{
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn('/nonexistent/path');
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size);
$file->method('getError')->willReturn(UPLOAD_ERR_OK);
$file->method('getStream')->willReturn($stream);
return $file;
}
/**
* Crée un mock d'UploadedFile pointant vers un fichier réel.
*/
private function makeUploadedFileFromPath(string $path, int $size): UploadedFileInterface
{
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn($path);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size);
$file->method('getError')->willReturn(UPLOAD_ERR_OK);
$file->method('getStream')->willReturn($stream);
$file->method('moveTo')->willReturnCallback(function (string $dest) use ($path) {
copy($path, $dest);
});
return $file;
}
/**
* Crée un fichier JPEG valide via GD (1×1 pixel).
* Retourne le chemin du fichier temporaire.
*/
private function createMinimalJpeg(): string
{
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_jpeg_') . '.jpg';
$img = imagecreatetruecolor(1, 1);
imagejpeg($img, $tmpFile);
imagedestroy($img);
return $tmpFile;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostRepository;
use App\Post\PostRepositoryInterface;
use App\Post\PostService;
use App\Shared\Database\Migrator;
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;
use PDO;
use PHPUnit\Framework\TestCase;
final class PostConcurrentUpdateIntegrationTest extends TestCase
{
private PDO $db;
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
Migrator::run($this->db);
$this->db->exec("INSERT INTO users (id, username, email, password_hash, role, created_at) VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')");
$this->db->exec("INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at) VALUES (1, 'Titre', '<p>Contenu</p>', 'titre', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')");
}
public function testUpdatePostThrowsWhenRowDisappearsBetweenReadAndWrite(): void
{
$realRepo = new PostRepository($this->db);
$repo = new class($realRepo) implements PostRepositoryInterface {
private bool $deleted = false;
public function __construct(private readonly PostRepository $inner) {}
public function findAll(?int $categoryId = null): array { return $this->inner->findAll($categoryId); }
public function 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 findBySlug(string $slug): ?Post { return $this->inner->findBySlug($slug); }
public function findById(int $id): ?Post { return $this->inner->findById($id); }
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int { return $this->inner->create($post, $slug, $authorId, $categoryId); }
public function update(int $id, Post $post, string $slug, ?int $categoryId): int {
if (!$this->deleted) {
$this->deleted = true;
$this->inner->delete($id);
}
return $this->inner->update($id, $post, $slug, $categoryId);
}
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 slugExists(string $slug, ?int $excludeId = null): bool { return $this->inner->slugExists($slug, $excludeId); }
};
$sanitizer = new class implements HtmlSanitizerInterface {
public function sanitize(string $html): string { return $html; }
};
$service = new PostService($repo, $sanitizer);
$this->expectException(NotFoundException::class);
$service->updatePost(1, 'Titre modifié', '<p>Contenu modifié</p>');
}
}

View File

@@ -0,0 +1,505 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Category\Category;
use App\Category\CategoryServiceInterface;
use App\Post\Post;
use App\Post\PostController;
use App\Post\PostServiceInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Slim\Exception\HttpNotFoundException;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour PostController.
*
* Couvre les 7 actions publiques :
* - index() : page d'accueil, recherche FTS, filtre catégorie
* - show() : article trouvé, article introuvable (404)
* - admin() : admin/éditeur voit tout, utilisateur ordinaire voit le sien
* - form() : formulaire nouveau, formulaire édition, droits insuffisants, 404
* - create() : succès, erreur de validation, erreur inattendue
* - update() : 404, droits insuffisants, succès, erreur de validation
* - delete() : 404, droits insuffisants, succès
*/
final class PostControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var PostServiceInterface&MockObject */
private PostServiceInterface $postService;
/** @var CategoryServiceInterface&MockObject */
private CategoryServiceInterface $categoryService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private PostController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->postService = $this->createMock(PostServiceInterface::class);
$this->categoryService = $this->createMock(CategoryServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->categoryService->method('findAll')->willReturn([]);
$this->controller = new PostController(
$this->view,
$this->postService,
$this->categoryService,
$this->flash,
$this->sessionManager,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit appeler getAllPosts() sans filtre par défaut.
*/
public function testIndexCallsGetAllPostsWithNoFilter(): void
{
$this->postService->expects($this->once())->method('getAllPosts')->with(null)->willReturn([]);
$this->postService->expects($this->never())->method('searchPosts');
$res = $this->controller->index($this->makeGet('/'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* index() doit appeler searchPosts() quand un paramètre q est fourni.
*/
public function testIndexCallsSearchPostsWhenQueryParamPresent(): void
{
$this->postService->expects($this->once())
->method('searchPosts')
->with('php', null)
->willReturn([]);
$this->postService->expects($this->never())->method('getAllPosts');
$this->controller->index($this->makeGet('/', ['q' => 'php']), $this->makeResponse());
}
/**
* index() doit résoudre le slug de catégorie et filtrer si le paramètre categorie est fourni.
*/
public function testIndexFiltersByCategoryWhenSlugProvided(): void
{
$category = new Category(3, 'PHP', 'php');
$this->categoryService->method('findBySlug')->with('php')->willReturn($category);
$this->postService->expects($this->once())
->method('getAllPosts')
->with(3)
->willReturn([]);
$this->controller->index(
$this->makeGet('/', ['categorie' => 'php']),
$this->makeResponse(),
);
}
// ── show ─────────────────────────────────────────────────────────
/**
* show() doit rendre la vue de détail si l'article est trouvé.
*/
public function testShowRendersDetailView(): void
{
$post = $this->buildPostEntity(1, 'Titre', 'Contenu', 'titre', 1);
$this->postService->method('getPostBySlug')->willReturn($post);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'pages/post/detail.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->show(
$this->makeGet('/article/titre'),
$this->makeResponse(),
['slug' => 'titre'],
);
$this->assertStatus($res, 200);
}
/**
* show() doit lancer HttpNotFoundException si l'article est introuvable.
*/
public function testShowThrowsHttpNotFoundWhenPostMissing(): void
{
$this->postService->method('getPostBySlug')
->willThrowException(new NotFoundException('Article', 'missing'));
$this->expectException(HttpNotFoundException::class);
$this->controller->show(
$this->makeGet('/article/missing'),
$this->makeResponse(),
['slug' => 'missing'],
);
}
// ── admin ────────────────────────────────────────────────────────
/**
* admin() doit appeler getAllPosts() pour un administrateur.
*/
public function testAdminCallsGetAllPostsForAdmin(): void
{
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->postService->expects($this->once())->method('getAllPosts')->willReturn([]);
$this->postService->expects($this->never())->method('getPostsByUserId');
$res = $this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* admin() doit appeler getPostsByUserId() pour un utilisateur ordinaire.
*/
public function testAdminCallsGetPostsByUserIdForRegularUser(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->postService->expects($this->once())->method('getPostsByUserId')->with(5, null)->willReturn([]);
$this->postService->expects($this->never())->method('getAllPosts');
$this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse());
}
/**
* admin() doit passer authorId = null à searchPosts() pour un admin (recherche globale).
*/
public function testAdminSearchPassesNullAuthorIdForAdmin(): void
{
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->postService->expects($this->once())
->method('searchPosts')
->with('php', null, null)
->willReturn([]);
$this->controller->admin(
$this->makeGet('/admin/posts', ['q' => 'php']),
$this->makeResponse(),
);
}
// ── form ─────────────────────────────────────────────────────────
/**
* form() doit rendre le formulaire vide pour un nouvel article (id = 0).
*/
public function testFormRendersEmptyFormForNewPost(): void
{
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/posts/form.twig', $this->callback(
fn (array $d) => $d['post'] === null && $d['action'] === '/admin/posts/create'
))
->willReturnArgument(0);
$this->controller->form(
$this->makeGet('/admin/posts/edit/0'),
$this->makeResponse(),
['id' => '0'],
);
}
/**
* form() doit rendre le formulaire pré-rempli pour un article existant dont l'utilisateur est auteur.
*/
public function testFormRendersFilledFormWhenUserIsAuthor(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 5);
$this->postService->method('getPostById')->with(7)->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/posts/form.twig', $this->callback(
fn (array $d) => $d['post'] === $post && $d['action'] === '/admin/posts/edit/7'
))
->willReturnArgument(0);
$this->controller->form(
$this->makeGet('/admin/posts/edit/7'),
$this->makeResponse(),
['id' => '7'],
);
}
/**
* form() doit rediriger avec une erreur si l'utilisateur n'est pas l'auteur.
*/
public function testFormRedirectsWhenUserCannotEditPost(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 10); // auteur = 10
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5); // connecté = 5
$this->flash->expects($this->once())->method('set')
->with('post_error', $this->stringContains("n'êtes pas l'auteur"));
$res = $this->controller->form(
$this->makeGet('/admin/posts/edit/7'),
$this->makeResponse(),
['id' => '7'],
);
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* form() doit lancer HttpNotFoundException si l'article est introuvable.
*/
public function testFormThrowsHttpNotFoundWhenPostMissing(): void
{
$this->postService->method('getPostById')
->willThrowException(new NotFoundException('Article', 99));
$this->expectException(HttpNotFoundException::class);
$this->controller->form(
$this->makeGet('/admin/posts/edit/99'),
$this->makeResponse(),
['id' => '99'],
);
}
// ── create ───────────────────────────────────────────────────────
/**
* create() doit flasher un succès et rediriger vers /admin/posts en cas de succès.
*/
public function testCreateRedirectsToAdminPostsOnSuccess(): void
{
$this->sessionManager->method('getUserId')->willReturn(1);
$this->postService->method('createPost')->willReturn(0);
$this->flash->expects($this->once())->method('set')
->with('post_success', $this->stringContains('créé'));
$req = $this->makePost('/admin/posts/create', ['title' => 'Mon titre', 'content' => 'Contenu']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* create() doit flasher une erreur et rediriger vers le formulaire si la validation échoue.
*/
public function testCreateRedirectsToFormOnValidationError(): void
{
$this->postService->method('createPost')
->willThrowException(new \InvalidArgumentException('Titre vide'));
$this->flash->expects($this->once())->method('set')
->with('post_error', 'Titre vide');
$req = $this->makePost('/admin/posts/create', ['title' => '', 'content' => 'x']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/posts/edit/0');
}
/**
* create() doit flasher une erreur générique et rediriger vers le formulaire en cas d'exception inattendue.
*/
public function testCreateRedirectsToFormOnUnexpectedError(): void
{
$this->postService->method('createPost')
->willThrowException(new \RuntimeException('DB error'));
$this->flash->expects($this->once())->method('set')
->with('post_error', $this->stringContains('inattendue'));
$req = $this->makePost('/admin/posts/create', ['title' => 'T', 'content' => 'C']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/posts/edit/0');
}
// ── update ───────────────────────────────────────────────────────
/**
* update() doit lancer HttpNotFoundException si l'article est introuvable.
*/
public function testUpdateThrowsHttpNotFoundWhenPostMissing(): void
{
$this->postService->method('getPostById')
->willThrowException(new NotFoundException('Article', 42));
$this->expectException(HttpNotFoundException::class);
$req = $this->makePost('/admin/posts/edit/42', ['title' => 'T', 'content' => 'C', 'slug' => 's']);
$this->controller->update($req, $this->makeResponse(), ['id' => '42']);
}
/**
* update() doit rediriger avec une erreur si l'utilisateur n'est pas l'auteur.
*/
public function testUpdateRedirectsWhenUserCannotEditPost(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 10);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->flash->expects($this->once())->method('set')
->with('post_error', $this->stringContains("n'êtes pas l'auteur"));
$req = $this->makePost('/admin/posts/edit/7', ['title' => 'T', 'content' => 'C', 'slug' => 's']);
$res = $this->controller->update($req, $this->makeResponse(), ['id' => '7']);
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* update() doit rediriger vers /admin/posts avec un message de succès.
*/
public function testUpdateRedirectsToAdminPostsOnSuccess(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 5);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->flash->expects($this->once())->method('set')
->with('post_success', $this->stringContains('modifié'));
$req = $this->makePost('/admin/posts/edit/7', ['title' => 'Nouveau', 'content' => 'Contenu', 'slug' => 'nouveau']);
$res = $this->controller->update($req, $this->makeResponse(), ['id' => '7']);
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* update() doit rediriger vers le formulaire avec une erreur en cas d'InvalidArgumentException.
*/
public function testUpdateRedirectsToFormOnValidationError(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 5);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->postService->method('updatePost')
->willThrowException(new \InvalidArgumentException('Titre invalide'));
$this->flash->expects($this->once())->method('set')
->with('post_error', 'Titre invalide');
$req = $this->makePost('/admin/posts/edit/7', ['title' => '', 'content' => 'C', 'slug' => 's']);
$res = $this->controller->update($req, $this->makeResponse(), ['id' => '7']);
$this->assertRedirectTo($res, '/admin/posts/edit/7');
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit lancer HttpNotFoundException si l'article est introuvable.
*/
public function testDeleteThrowsHttpNotFoundWhenPostMissing(): void
{
$this->postService->method('getPostById')
->willThrowException(new NotFoundException('Article', 42));
$this->expectException(HttpNotFoundException::class);
$this->controller->delete(
$this->makePost('/admin/posts/delete/42'),
$this->makeResponse(),
['id' => '42'],
);
}
/**
* delete() doit rediriger avec une erreur si l'utilisateur n'est pas l'auteur.
*/
public function testDeleteRedirectsWhenUserCannotDeletePost(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 10);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->flash->expects($this->once())->method('set')
->with('post_error', $this->stringContains("n'êtes pas l'auteur"));
$res = $this->controller->delete(
$this->makePost('/admin/posts/delete/7'),
$this->makeResponse(),
['id' => '7'],
);
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* delete() doit appeler deletePost() et rediriger avec succès pour un auteur.
*/
public function testDeleteRedirectsWithSuccessForAuthor(): void
{
$post = $this->buildPostEntity(7, 'Mon article', 'Contenu', 'mon-article', 5);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->postService->expects($this->once())->method('deletePost')->with(7);
$this->flash->expects($this->once())->method('set')
->with('post_success', $this->stringContains('Mon article'));
$res = $this->controller->delete(
$this->makePost('/admin/posts/delete/7'),
$this->makeResponse(),
['id' => '7'],
);
$this->assertRedirectTo($res, '/admin/posts');
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Crée une entité Post de test avec les paramètres minimaux.
*
* Nommé buildPostEntity (et non makePost) pour ne pas masquer
* ControllerTestCase::makePost() qui forge une requête HTTP.
*/
private function buildPostEntity(
int $id,
string $title,
string $content,
string $slug,
?int $authorId,
): Post {
return new Post($id, $title, $content, $slug, $authorId);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostExtension;
use PHPUnit\Framework\TestCase;
use Twig\TwigFunction;
final class PostExtensionTest extends TestCase
{
/** @var array<string, TwigFunction> */
private array $functions;
protected function setUp(): void
{
$extension = new PostExtension();
$this->functions = [];
foreach ($extension->getFunctions() as $function) {
$this->functions[$function->getName()] = $function;
}
}
public function testPostUrlUsesStoredSlug(): void
{
$post = new Post(1, 'Mon article', '<p>Contenu</p>', 'mon-article-2');
self::assertSame('/article/mon-article-2', $this->call('post_url', $post));
}
public function testPostExcerptKeepsSafeTagsAndTruncatesHtml(): void
{
$html = '<p><strong>Bonjour</strong> <script>alert(1)</script><em>monde</em> ' . str_repeat('x', 30) . '</p>';
$post = new Post(1, 'Titre', $html, 'titre');
$excerpt = $this->call('post_excerpt', $post, 20);
self::assertStringContainsString('<strong>Bonjour</strong>', $excerpt);
self::assertStringContainsString('<em>', $excerpt);
self::assertStringNotContainsString('<script>', $excerpt);
self::assertStringEndsWith('…', $excerpt);
}
public function testPostThumbnailReturnsFirstImageSource(): void
{
$post = new Post(1, 'Titre', '<p><img src="/media/a.webp" alt="a"><img src="/media/b.webp"></p>', 'titre');
self::assertSame('/media/a.webp', $this->call('post_thumbnail', $post));
}
public function testPostThumbnailReturnsNullWhenMissing(): void
{
$post = new Post(1, 'Titre', '<p>Sans image</p>', 'titre');
self::assertNull($this->call('post_thumbnail', $post));
}
public function testPostInitialsUseMeaningfulWordsAndFallback(): void
{
$post = new Post(1, 'Article de Blog', '<p>Contenu</p>', 'slug');
$single = new Post(2, 'A B', '<p>Contenu</p>', 'slug-2');
$emptyLike = new Post(3, 'A', '<p>Contenu</p>', 'slug-3');
self::assertSame('AB', $this->call('post_initials', $post));
self::assertSame('A', $this->call('post_initials', $single));
self::assertSame('A', $this->call('post_initials', $emptyLike));
}
private function call(string $name, mixed ...$args): mixed
{
$callable = $this->functions[$name]->getCallable();
return $callable(...$args);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\PostRepository;
use App\Shared\Database\Migrator;
use PDO;
use PHPUnit\Framework\TestCase;
final class PostFtsUsernameSyncIntegrationTest extends TestCase
{
private PDO $db;
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
Migrator::run($this->db);
$this->db->exec("INSERT INTO users (id, username, email, password_hash, role, created_at) VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')");
$this->db->exec("INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at) VALUES (1, 'Guide Slim', '<p>Contenu</p>', 'guide-slim', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')");
}
public function testSearchReflectsUpdatedAuthorUsernameInFtsIndex(): void
{
$this->db->exec("UPDATE users SET username = 'alice_renamed' WHERE id = 1");
$results = (new PostRepository($this->db))->search('alice_renamed');
self::assertCount(1, $results);
self::assertSame('alice_renamed', $results[0]->getAuthorUsername());
self::assertSame('Guide Slim', $results[0]->getTitle());
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use PHPUnit\Framework\TestCase;
final class PostModelEdgeCasesTest extends TestCase
{
public function testFromArrayKeepsMissingOptionalFieldsNull(): void
{
$post = Post::fromArray([
'id' => 3,
'title' => 'Titre',
'content' => '<p>Texte</p>',
'slug' => 'titre',
]);
self::assertSame(3, $post->getId());
self::assertNull($post->getAuthorId());
self::assertNull($post->getAuthorUsername());
self::assertNull($post->getCategoryId());
self::assertNull($post->getCategoryName());
self::assertNull($post->getCategorySlug());
self::assertInstanceOf(\DateTime::class, $post->getCreatedAt());
self::assertInstanceOf(\DateTime::class, $post->getUpdatedAt());
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use DateTime;
use PHPUnit\Framework\TestCase;
final class PostModelTest extends TestCase
{
public function testConstructAndGettersExposePostData(): void
{
$createdAt = new DateTime('2026-01-01 10:00:00');
$updatedAt = new DateTime('2026-01-02 11:00:00');
$post = new Post(
10,
'Été en forêt',
'<p>Contenu</p>',
'ete-en-foret-2',
5,
'julien',
3,
'Tech',
'tech',
$createdAt,
$updatedAt,
);
self::assertSame(10, $post->getId());
self::assertSame('Été en forêt', $post->getTitle());
self::assertSame('<p>Contenu</p>', $post->getContent());
self::assertSame('ete-en-foret-2', $post->getStoredSlug());
self::assertSame('ete-en-foret', $post->generateSlug());
self::assertSame(5, $post->getAuthorId());
self::assertSame('julien', $post->getAuthorUsername());
self::assertSame(3, $post->getCategoryId());
self::assertSame('Tech', $post->getCategoryName());
self::assertSame('tech', $post->getCategorySlug());
self::assertSame($createdAt, $post->getCreatedAt());
self::assertSame($updatedAt, $post->getUpdatedAt());
}
public function testFromArrayHydratesOptionalFields(): void
{
$post = Post::fromArray([
'id' => '7',
'title' => 'Titre',
'content' => '<p>Texte</p>',
'slug' => 'titre',
'author_id' => '9',
'author_username' => 'alice',
'category_id' => '2',
'category_name' => 'Actualités',
'category_slug' => 'actualites',
'created_at' => '2026-02-01 10:00:00',
'updated_at' => '2026-02-02 11:00:00',
]);
self::assertSame(7, $post->getId());
self::assertSame('alice', $post->getAuthorUsername());
self::assertSame('Actualités', $post->getCategoryName());
self::assertSame('2026-02-01 10:00:00', $post->getCreatedAt()->format('Y-m-d H:i:s'));
}
public function testValidationRejectsEmptyTitle(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le titre ne peut pas être vide');
new Post(1, '', '<p>Contenu</p>');
}
public function testValidationRejectsTooLongTitle(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le titre ne peut pas dépasser 255 caractères');
new Post(1, str_repeat('a', 256), '<p>Contenu</p>');
}
public function testValidationRejectsEmptyContent(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le contenu ne peut pas être vide');
new Post(1, 'Titre', '');
}
public function testValidationRejectsTooLongContent(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le contenu ne peut pas dépasser 65 535 caractères');
new Post(1, 'Titre', str_repeat('a', 65536));
}
}

View File

@@ -0,0 +1,546 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PostRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
final class PostRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private PostRepository $repository;
/**
* Données représentant une ligne article en base de données (avec JOINs).
*
* @var array<string, mixed>
*/
private array $rowPost;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new PostRepository($this->db);
$this->rowPost = [
'id' => 1,
'title' => 'Introduction à PHP',
'content' => '<p>Contenu de test</p>',
'slug' => 'introduction-a-php',
'author_id' => 2,
'author_username' => 'alice',
'category_id' => 3,
'category_name' => 'PHP',
'category_slug' => 'php',
'created_at' => '2024-01-01 00:00:00',
'updated_at' => '2024-01-02 00:00:00',
];
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un PDOStatement mock préconfiguré pour les requêtes de lecture.
*
* @param list<array<string,mixed>> $rows Lignes retournées par fetchAll()
* @param array<string,mixed>|false $row Ligne retournée par fetch()
*/
private function stmtForRead(array $rows = [], array|false $row = false): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchAll')->willReturn($rows);
$stmt->method('fetch')->willReturn($row);
return $stmt;
}
/**
* Crée un PDOStatement mock préconfiguré pour les requêtes d'écriture.
*/
private function stmtForWrite(int $rowCount = 1): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('rowCount')->willReturn($rowCount);
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() sans filtre utilise query() (pas de paramètre à lier)
* et retourne un tableau vide si aucun article n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() retourne des instances Post hydratées.
*/
public function testFindAllReturnsPostInstances(): void
{
$stmt = $this->stmtForRead([$this->rowPost]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(Post::class, $result[0]);
$this->assertSame('Introduction à PHP', $result[0]->getTitle());
$this->assertSame('alice', $result[0]->getAuthorUsername());
}
/**
* findAll() sans filtre appelle query() et non prepare()
* (pas de paramètre à lier).
*/
public function testFindAllWithoutFilterUsesQueryNotPrepare(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())->method('query')->willReturn($stmt);
$this->db->expects($this->never())->method('prepare');
$this->repository->findAll();
}
/**
* findAll() avec categoryId prépare un SQL contenant la clause WHERE category_id
* et exécute avec le bon paramètre.
*/
public function testFindAllWithCategoryIdPassesFilterCorrectly(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('category_id'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool =>
isset($p[':category_id']) && $p[':category_id'] === 3
));
$this->repository->findAll(3);
}
// ── findRecent ─────────────────────────────────────────────────
/**
* findRecent() retourne un tableau vide si aucun article n'existe.
*/
public function testFindRecentReturnsEmptyArray(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findRecent(5));
}
/**
* findRecent() lie la limite via bindValue() avec le bon entier.
*/
public function testFindRecentPassesLimitCorrectly(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('bindValue')
->with(':limit', 10, PDO::PARAM_INT);
$this->repository->findRecent(10);
}
/**
* findRecent() retourne des instances Post hydratées.
*/
public function testFindRecentReturnsPostInstances(): void
{
$stmt = $this->stmtForRead([$this->rowPost]);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findRecent(1);
$this->assertCount(1, $result);
$this->assertInstanceOf(Post::class, $result[0]);
}
// ── findByUserId ───────────────────────────────────────────────
/**
* findByUserId() retourne un tableau vide si l'utilisateur n'a aucun article.
*/
public function testFindByUserIdReturnsEmptyArray(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findByUserId(99));
}
/**
* findByUserId() exécute avec le bon author_id.
*/
public function testFindByUserIdPassesCorrectParameters(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool =>
isset($p[':author_id']) && $p[':author_id'] === 7
));
$this->repository->findByUserId(7);
}
/**
* findByUserId() avec categoryId exécute avec les deux filtres.
*/
public function testFindByUserIdWithCategoryIdPassesBothFilters(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool =>
isset($p[':author_id'], $p[':category_id'])
&& $p[':author_id'] === 7
&& $p[':category_id'] === 3
));
$this->repository->findByUserId(7, 3);
}
// ── findBySlug ─────────────────────────────────────────────────
/**
* findBySlug() retourne null si le slug est absent.
*/
public function testFindBySlugReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findBySlug('inexistant'));
}
/**
* findBySlug() retourne une instance Post si le slug existe.
*/
public function testFindBySlugReturnsPostWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowPost);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findBySlug('introduction-a-php');
$this->assertInstanceOf(Post::class, $result);
$this->assertSame('introduction-a-php', $result->getStoredSlug());
}
/**
* findBySlug() exécute avec le bon slug.
*/
public function testFindBySlugQueriesWithCorrectSlug(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':slug' => 'mon-article']);
$this->repository->findBySlug('mon-article');
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si l'article est absent.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(999));
}
/**
* findById() retourne une instance Post si l'article existe.
*/
public function testFindByIdReturnsPostWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowPost);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(Post::class, $result);
$this->assertSame(1, $result->getId());
}
/**
* findById() exécute avec le bon identifiant.
*/
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 12]);
$this->repository->findById(12);
}
// ── create ─────────────────────────────────────────────────────
/**
* create() prépare un INSERT avec les bonnes colonnes.
*/
public function testCreateCallsInsertWithCorrectData(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('INSERT INTO posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($post): bool {
return $data[':title'] === $post->getTitle()
&& $data[':content'] === $post->getContent()
&& $data[':slug'] === 'introduction-a-php'
&& $data[':author_id'] === 5
&& $data[':category_id'] === 3
&& isset($data[':created_at'], $data[':updated_at']);
}));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($post, 'introduction-a-php', 5, 3);
}
/**
* create() retourne l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('42');
$this->assertSame(42, $this->repository->create($post, 'slug', 1, null));
}
/**
* create() accepte un categoryId null (article sans catégorie).
*/
public function testCreateAcceptsCategoryIdNull(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $data): bool =>
array_key_exists(':category_id', $data) && $data[':category_id'] === null
));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($post, 'slug', 1, null);
}
// ── update ─────────────────────────────────────────────────────
/**
* update() prépare un UPDATE avec les bonnes colonnes et le bon identifiant.
*/
public function testUpdateCallsUpdateWithCorrectData(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite(1);
$this->db->method('prepare')
->with($this->stringContains('UPDATE posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($post): bool {
return $data[':title'] === $post->getTitle()
&& $data[':content'] === $post->getContent()
&& $data[':slug'] === 'nouveau-slug'
&& $data[':category_id'] === 3
&& $data[':id'] === 1
&& isset($data[':updated_at']);
}));
$this->repository->update(1, $post, 'nouveau-slug', 3);
}
/**
* update() retourne le nombre de lignes affectées.
*/
public function testUpdateReturnsAffectedRowCount(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(1, $this->repository->update(1, $post, 'slug', null));
}
/**
* update() retourne 0 si aucune ligne n'est modifiée (article supprimé entre-temps).
*/
public function testUpdateReturnsZeroWhenNoRowAffected(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite(0);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(0, $this->repository->update(1, $post, 'slug', null));
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() prépare un DELETE avec le bon identifiant.
*/
public function testDeleteCallsDeleteWithCorrectId(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 6]);
$this->repository->delete(6);
}
/**
* delete() retourne le nombre de lignes supprimées.
*/
public function testDeleteReturnsDeletedRowCount(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(1, $this->repository->delete(6));
}
/**
* delete() retourne 0 si l'article n'existait plus.
*/
public function testDeleteReturnsZeroWhenNotFound(): void
{
$stmt = $this->stmtForWrite(0);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(0, $this->repository->delete(99));
}
// ── slugExists ─────────────────────────────────────────────────
/**
* slugExists() retourne false si le slug n'existe pas en base.
*/
public function testSlugExistsReturnsFalseWhenMissing(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertFalse($this->repository->slugExists('slug-libre'));
}
/**
* slugExists() retourne true si le slug est pris par un autre article.
*/
public function testSlugExistsReturnsTrueWhenTaken(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn('1');
$this->db->method('prepare')->willReturn($stmt);
$this->assertTrue($this->repository->slugExists('slug-pris'));
}
/**
* slugExists() retourne false si le slug appartient à l'article exclu (mise à jour).
*/
public function testSlugExistsReturnsFalseForExcludedPost(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn('5');
$this->db->method('prepare')->willReturn($stmt);
$this->assertFalse($this->repository->slugExists('mon-slug', 5));
}
/**
* slugExists() retourne true si le slug appartient à un article différent de l'exclu.
*/
public function testSlugExistsReturnsTrueForOtherPost(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn('3');
$this->db->method('prepare')->willReturn($stmt);
$this->assertTrue($this->repository->slugExists('mon-slug', 5));
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostRepositoryInterface;
use App\Post\PostService;
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PostService.
*
* Couvre la création, la mise à jour, la suppression et les lectures.
* HtmlSanitizerInterface et PostRepository sont mockés pour isoler la logique métier.
*/
final class PostServiceTest extends TestCase
{
/** @var PostRepositoryInterface&MockObject */
private PostRepositoryInterface $repository;
/** @var HtmlSanitizerInterface&MockObject */
private HtmlSanitizerInterface $sanitizer;
private PostService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(PostRepositoryInterface::class);
$this->sanitizer = $this->createMock(HtmlSanitizerInterface::class);
$this->service = new PostService($this->repository, $this->sanitizer);
}
// ── Lectures déléguées ─────────────────────────────────────────
/**
* getAllPosts() délègue au repository.
*/
public function testGetAllPostsDelegatesToRepository(): void
{
$posts = [$this->makePost(1, 'Titre', 'slug-titre')];
$this->repository->method('findAll')->willReturn($posts);
$this->assertSame($posts, $this->service->getAllPosts());
}
/**
* getRecentPosts() délègue au repository.
*/
public function testGetRecentPostsDelegatesToRepository(): void
{
$posts = [$this->makePost(1, 'Titre', 'slug-titre')];
$this->repository->method('findRecent')->willReturn($posts);
$this->assertSame($posts, $this->service->getRecentPosts(5));
}
/**
* getPostsByUserId() délègue au repository.
*/
public function testGetPostsByUserIdDelegatesToRepository(): void
{
$posts = [$this->makePost(1, 'Titre', 'slug-titre')];
$this->repository->method('findByUserId')->with(3, null)->willReturn($posts);
$this->assertSame($posts, $this->service->getPostsByUserId(3));
}
/**
* getPostBySlug() lève NotFoundException si l'article est introuvable.
*/
public function testGetPostBySlugThrowsNotFoundExceptionWhenMissing(): void
{
$this->repository->method('findBySlug')->willReturn(null);
$this->expectException(NotFoundException::class);
$this->service->getPostBySlug('slug-inexistant');
}
/**
* getPostBySlug() retourne l'article tel que stocké — la sanitisation
* se fait à l'écriture (createPost / updatePost), pas à la lecture.
*/
public function testGetPostBySlugReturnsPost(): void
{
$post = $this->makePost(1, 'Titre', 'mon-slug', '<p>Contenu stocké</p>');
$this->repository->method('findBySlug')->willReturn($post);
$result = $this->service->getPostBySlug('mon-slug');
$this->assertSame('<p>Contenu stocké</p>', $result->getContent());
}
/**
* getPostById() lève NotFoundException si l'article est introuvable.
*/
public function testGetPostByIdThrowsNotFoundExceptionWhenMissing(): void
{
$this->repository->method('findById')->willReturn(null);
$this->expectException(NotFoundException::class);
$this->service->getPostById(999);
}
// ── createPost ─────────────────────────────────────────────────
/**
* createPost() lève InvalidArgumentException si le titre est vide.
*/
public function testCreatePostThrowsWhenTitleEmpty(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->createPost('', '<p>Contenu</p>', 1);
}
/**
* createPost() lève InvalidArgumentException si le contenu est vide.
*/
public function testCreatePostThrowsWhenContentEmpty(): void
{
$this->sanitizer->method('sanitize')->willReturn('');
$this->expectException(\InvalidArgumentException::class);
$this->service->createPost('Titre', '', 1);
}
/**
* createPost() sanitise le contenu et délègue la persistance.
*/
public function testCreatePostSanitizesAndPersists(): void
{
$this->sanitizer->method('sanitize')->willReturn('<p>Contenu sûr</p>');
$this->repository->method('findBySlug')->willReturn(null);
$this->repository->expects($this->once())->method('create')->willReturn(42);
$id = $this->service->createPost('Mon Titre', '<p>Contenu brut</p>', 1);
$this->assertSame(42, $id);
}
// ── deletePost ─────────────────────────────────────────────────
/**
* deletePost() throws NotFoundException when the repository returns 0 affected rows.
*/
public function testDeletePostThrowsNotFoundExceptionWhenMissing(): void
{
$this->repository->method('delete')->willReturn(0);
$this->expectException(NotFoundException::class);
$this->service->deletePost(99);
}
/**
* deletePost() délègue la suppression au repository si l'article existe.
*/
public function testDeletePostDelegatesToRepository(): void
{
$this->repository->expects($this->once())->method('delete')->with(5)->willReturn(1);
$this->service->deletePost(5);
}
// ── searchPosts ────────────────────────────────────────────────
/**
* searchPosts() délègue la recherche au repository.
*/
public function testSearchPostsDelegatesToRepository(): void
{
$posts = [$this->makePost(1, 'Résultat', 'resultat')];
$this->repository->method('search')->with('mot', null, null)->willReturn($posts);
$this->assertSame($posts, $this->service->searchPosts('mot'));
}
// ── updatePost ─────────────────────────────────────────────────
/**
* updatePost() lève NotFoundException si l'article n'existe plus.
*/
public function testUpdatePostThrowsNotFoundExceptionWhenMissing(): void
{
$this->repository->method('findById')->willReturn(null);
$this->expectException(NotFoundException::class);
$this->service->updatePost(99, 'Titre', '<p>Contenu</p>');
}
/**
* updatePost() sanitise le contenu et met à jour l'article.
*/
public function testUpdatePostSanitizesAndUpdates(): void
{
$post = $this->makePost(1, 'Ancien titre', 'ancien-titre', '<p>Ancien contenu</p>');
$this->repository->method('findById')->willReturn($post);
$this->sanitizer->method('sanitize')->willReturn('<p>Nouveau contenu sûr</p>');
$this->repository->method('findBySlug')->willReturn(null);
$this->repository->expects($this->once())->method('update')->willReturn(1);
$this->service->updatePost(1, 'Nouveau titre', '<p>Nouveau contenu</p>');
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un Post de test.
*/
private function makePost(int $id, string $title, string $slug, string $content = '<p>Contenu</p>'): Post
{
return new Post($id, $title, $content, $slug);
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostServiceInterface;
use App\Post\RssController;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour RssController.
*
* Couvre feed() :
* - Content-Type application/rss+xml
* - Structure XML valide (balises channel obligatoires)
* - Articles inclus dans le flux (titre, lien, guid)
* - Flux vide : XML minimal valide
* - Appel à getRecentPosts() avec la constante FEED_LIMIT (20)
*/
final class RssControllerTest extends ControllerTestCase
{
/** @var PostServiceInterface&MockObject */
private PostServiceInterface $postService;
private RssController $controller;
private const APP_URL = 'https://example.com';
private const APP_NAME = 'Mon Blog';
protected function setUp(): void
{
$this->postService = $this->createMock(PostServiceInterface::class);
$this->controller = new RssController(
$this->postService,
self::APP_URL,
self::APP_NAME,
);
}
/**
* feed() doit retourner un Content-Type application/rss+xml.
*/
public function testFeedReturnsRssContentType(): void
{
$this->postService->method('getRecentPosts')->willReturn([]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$this->assertStringContainsString('application/rss+xml', $res->getHeaderLine('Content-Type'));
}
/**
* feed() doit retourner un XML valide même si aucun article n'existe.
*/
public function testFeedReturnsValidXmlWhenNoPostsExist(): void
{
$this->postService->method('getRecentPosts')->willReturn([]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$body = (string) $res->getBody();
$xml = simplexml_load_string($body);
$this->assertNotFalse($xml, 'Le corps de la réponse doit être du XML valide');
$this->assertSame('2.0', (string) $xml['version']);
}
/**
* feed() doit inclure les balises channel obligatoires (title, link, description).
*/
public function testFeedIncludesRequiredChannelElements(): void
{
$this->postService->method('getRecentPosts')->willReturn([]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$channel = $xml->channel;
$this->assertSame(self::APP_NAME, (string) $channel->title);
$this->assertNotEmpty((string) $channel->link);
$this->assertNotEmpty((string) $channel->description);
}
/**
* feed() doit inclure un item par article avec title, link et guid.
*/
public function testFeedIncludesOneItemPerPost(): void
{
$post = new Post(1, 'Titre test', 'Contenu de test', 'titre-test', 1, 'alice');
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$items = $xml->channel->item;
$this->assertCount(1, $items);
$this->assertSame('Titre test', (string) $items[0]->title);
$this->assertStringContainsString('titre-test', (string) $items[0]->link);
$this->assertSame((string) $items[0]->link, (string) $items[0]->guid);
}
/**
* feed() doit tronquer le contenu à 300 caractères dans la description.
*/
public function testFeedTruncatesLongContentTo300Chars(): void
{
$longContent = str_repeat('a', 400);
$post = new Post(1, 'Titre', $longContent, 'titre', 1);
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$description = (string) $xml->channel->item[0]->description;
// 300 caractères + '…' = 301 octets UTF-8 pour le contenu visible
$this->assertLessThanOrEqual(302, mb_strlen($description));
$this->assertStringEndsWith('…', $description);
}
/**
* feed() doit appeler getRecentPosts() avec la limite de 20 articles.
*/
public function testFeedRequestsTwentyRecentPosts(): void
{
$this->postService->expects($this->once())
->method('getRecentPosts')
->with(20)
->willReturn([]);
$this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
}
/**
* feed() doit inclure la balise author si l'article a un auteur.
*/
public function testFeedIncludesAuthorWhenPresent(): void
{
$post = new Post(1, 'Titre', 'Contenu', 'titre', 1, 'alice');
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$this->assertSame('alice', (string) $xml->channel->item[0]->author);
}
/**
* feed() ne doit pas inclure la balise author si l'auteur est null.
*/
public function testFeedOmitsAuthorWhenNull(): void
{
$post = new Post(1, 'Titre', 'Contenu', 'titre', null, null);
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$this->assertCount(0, $xml->channel->item[0]->author);
}
/**
* feed() doit construire les URLs des articles en utilisant APP_URL.
*/
public function testFeedBuildsPostUrlsWithAppUrl(): void
{
$post = new Post(1, 'Titre', 'Contenu', 'mon-slug', 1);
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$this->assertStringStartsWith(self::APP_URL, (string) $xml->channel->item[0]->link);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\ClientIpResolver;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
final class ClientIpResolverTest extends TestCase
{
public function testResolveReturnsDefaultWhenRemoteAddrMissing(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/');
$resolver = new ClientIpResolver();
self::assertSame('0.0.0.0', $resolver->resolve($request));
}
public function testResolveReturnsRemoteAddrWhenProxyNotTrusted(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
'REMOTE_ADDR' => '10.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.10',
]);
$resolver = new ClientIpResolver(['127.0.0.1']);
self::assertSame('10.0.0.1', $resolver->resolve($request));
}
public function testResolveReturnsForwardedIpWhenProxyTrusted(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.10, 198.51.100.12',
]);
$resolver = new ClientIpResolver(['127.0.0.1']);
self::assertSame('203.0.113.10', $resolver->resolve($request));
}
public function testResolveFallsBackToRemoteAddrWhenForwardedIpInvalid(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => 'not-an-ip',
]);
$resolver = new ClientIpResolver(['*']);
self::assertSame('127.0.0.1', $resolver->resolve($request));
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Config;
use PHPUnit\Framework\TestCase;
final class ConfigTest extends TestCase
{
public function testGetTwigCacheReturnsFalseInDev(): void
{
self::assertFalse(Config::getTwigCache(true));
}
public function testGetTwigCacheReturnsCachePathOutsideDev(): void
{
$cachePath = Config::getTwigCache(false);
self::assertIsString($cachePath);
self::assertStringEndsWith('/var/cache/twig', $cachePath);
}
public function testGetDatabasePathCreatesDatabaseFileWhenMissing(): void
{
$dbFile = dirname(__DIR__, 2).'/database/app.sqlite';
$dbDir = dirname($dbFile);
$backup = $dbFile.'.bak-test';
if (file_exists($backup)) {
@unlink($backup);
}
if (file_exists($dbFile)) {
rename($dbFile, $backup);
}
@unlink($dbFile);
try {
$path = Config::getDatabasePath();
self::assertSame($dbFile, $path);
self::assertDirectoryExists($dbDir);
self::assertFileExists($dbFile);
} finally {
@unlink($dbFile);
if (file_exists($backup)) {
rename($backup, $dbFile);
}
}
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Util\DateParser;
use DateTime;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour DateParser::parse().
*
* Couvre la conversion de valeurs brutes de base de données en DateTime,
* ainsi que les cas silencieux (null, chaîne vide, valeur invalide).
*/
final class DateParserTest extends TestCase
{
// ── Valeurs valides ────────────────────────────────────────────
/**
* Une date au format SQLite standard doit produire un DateTime correct.
*/
public function testStandardSqliteDate(): void
{
$result = DateParser::parse('2024-06-15 10:30:00');
$this->assertInstanceOf(DateTime::class, $result);
$this->assertSame('2024-06-15', $result->format('Y-m-d'));
$this->assertSame('10:30:00', $result->format('H:i:s'));
}
/**
* Une date ISO 8601 doit être correctement parsée.
*/
public function testIso8601Date(): void
{
$result = DateParser::parse('2024-01-01T00:00:00');
$this->assertInstanceOf(DateTime::class, $result);
$this->assertSame('2024-01-01', $result->format('Y-m-d'));
}
/**
* Une date seule (sans heure) doit être parsée correctement.
*/
public function testDateOnly(): void
{
$result = DateParser::parse('2024-12-31');
$this->assertInstanceOf(DateTime::class, $result);
$this->assertSame('2024-12-31', $result->format('Y-m-d'));
}
// ── Valeurs silencieuses — doit retourner null sans exception ────
/**
* null doit retourner null.
*/
public function testNullReturnsNull(): void
{
$this->assertNull(DateParser::parse(null));
}
/**
* Une chaîne vide doit retourner null.
*/
public function testEmptyStringReturnsNull(): void
{
$this->assertNull(DateParser::parse(''));
}
/**
* Une valeur non parseable doit retourner null sans lever d'exception.
*/
public function testInvalidValueReturnsNull(): void
{
$this->assertNull(DateParser::parse('pas-une-date'));
}
/**
* Un entier doit être interprété comme un timestamp si valide,
* ou retourner null si la conversion échoue.
* Ici on vérifie simplement qu'aucune exception n'est levée.
*/
public function testIntegerThrowsNoException(): void
{
$result = DateParser::parse(0);
// Pas d'assertion sur la valeur — on vérifie juste la robustesse
$this->assertTrue($result === null || $result instanceof DateTime);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Extension\AppExtension;
use App\Shared\Extension\CsrfExtension;
use App\Shared\Extension\SessionExtension;
use PHPUnit\Framework\TestCase;
use Slim\Csrf\Guard;
use Slim\Psr7\Factory\ResponseFactory;
final class ExtensionTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
public function testAppExtensionExposesAppUrl(): void
{
$extension = new AppExtension('https://example.test');
self::assertSame(['app_url' => 'https://example.test'], $extension->getGlobals());
}
public function testSessionExtensionExposesSelectedSessionKeys(): void
{
$_SESSION = [
'user_id' => 12,
'username' => 'julien',
'role' => 'admin',
'flash' => ['notice' => 'x'],
];
$extension = new SessionExtension();
self::assertSame([
'session' => [
'user_id' => 12,
'username' => 'julien',
'role' => 'admin',
],
], $extension->getGlobals());
}
public function testCsrfExtensionExposesTokens(): void
{
$storage = [];
$guard = new Guard(new ResponseFactory(), storage: $storage);
$extension = new CsrfExtension($guard);
$globals = $extension->getGlobals();
self::assertArrayHasKey('csrf', $globals);
self::assertSame($guard->getTokenNameKey(), $globals['csrf']['keys']['name']);
self::assertSame($guard->getTokenValueKey(), $globals['csrf']['keys']['value']);
self::assertSame($guard->getTokenName(), $globals['csrf']['name']);
self::assertSame($guard->getTokenValue(), $globals['csrf']['value']);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\FlashService;
use PHPUnit\Framework\TestCase;
final class FlashServiceConsumeTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
public function testGetReturnsNullWhenMissingAndConsumesWhenPresent(): void
{
$flash = new FlashService();
self::assertNull($flash->get('missing'));
$flash->set('notice', 'Bonjour');
self::assertSame('Bonjour', $flash->get('notice'));
self::assertNull($flash->get('notice'));
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\FlashService;
use PHPUnit\Framework\TestCase;
final class FlashServiceTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
public function testSetAndGetConsumesFlashMessage(): void
{
$flash = new FlashService();
$flash->set('notice', 'Bonjour');
self::assertSame('Bonjour', $flash->get('notice'));
self::assertNull($flash->get('notice'));
}
public function testGetCastsNonStringValueAndRemovesIt(): void
{
$_SESSION['flash']['count'] = 123;
$flash = new FlashService();
self::assertSame('123', $flash->get('count'));
self::assertArrayNotHasKey('count', $_SESSION['flash']);
}
public function testGetReturnsNullWhenMissing(): void
{
$flash = new FlashService();
self::assertNull($flash->get('missing'));
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\ClientIpResolver;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
final class HelperEdgeCasesTest extends TestCase
{
public function testClientIpResolverFallsBackToRemoteAddr(): void
{
$resolver = new ClientIpResolver([]);
$request = new ServerRequestFactory()->createServerRequest(
'GET',
'/',
['REMOTE_ADDR' => '127.0.0.1']
);
self::assertSame('127.0.0.1', $resolver->resolve($request));
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Html\HtmlPurifierFactory;
use PHPUnit\Framework\TestCase;
final class HtmlPurifierFactoryTest extends TestCase
{
public function testCreateBuildsPurifierAndSanitizesDangerousHtml(): void
{
$cacheDir = sys_get_temp_dir().'/htmlpurifier-test-'.bin2hex(random_bytes(4));
try {
$purifier = HtmlPurifierFactory::create($cacheDir);
$result = $purifier->purify('<p style="text-align:center">ok</p><a href="javascript:alert(1)">x</a> https://example.test');
self::assertDirectoryExists($cacheDir);
self::assertStringContainsString('text-align:center', $result);
self::assertStringNotContainsString('javascript:', $result);
self::assertStringContainsString('https://example.test', $result);
} finally {
if (is_dir($cacheDir)) {
foreach (glob($cacheDir.'/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($cacheDir);
}
}
}
}

View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Html\HtmlPurifierFactory;
use App\Shared\Html\HtmlSanitizer;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour HtmlSanitizer.
*
* Vérifie que HTMLPurifier supprime bien les contenus dangereux
* (XSS, balises non autorisées, schémas URI interdits) et conserve
* les balises légitimes produites par l'éditeur Trumbowyg.
*
* Ces tests utilisent une vraie instance HTMLPurifier (pas de mock)
* car c'est le comportement de purification lui-même qui est testé.
*/
final class HtmlSanitizerTest extends TestCase
{
private HtmlSanitizer $sanitizer;
/**
* Crée une instance réelle de HtmlSanitizer avant chaque test.
*/
protected function setUp(): void
{
$purifier = HtmlPurifierFactory::create(sys_get_temp_dir() . '/htmlpurifier_tests');
$this->sanitizer = new HtmlSanitizer($purifier);
}
// ── Balises autorisées ─────────────────────────────────────────
/**
* Les balises de texte courantes doivent être conservées.
*/
public function testTextTagsPreserved(): void
{
$html = '<p>Un <strong>texte</strong> avec <em>emphase</em> et <u>soulignement</u>.</p>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<strong>texte</strong>', $result);
$this->assertStringContainsString('<em>emphase</em>', $result);
$this->assertStringContainsString('<u>soulignement</u>', $result);
}
/**
* Les titres h1 à h6 doivent être conservés.
*/
public function testHeadingsPreserved(): void
{
$html = '<h1>Titre 1</h1><h2>Titre 2</h2><h3>Titre 3</h3>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<h1>', $result);
$this->assertStringContainsString('<h2>', $result);
$this->assertStringContainsString('<h3>', $result);
}
/**
* Les listes ordonnées et non ordonnées doivent être conservées.
*/
public function testListsPreserved(): void
{
$html = '<ul><li>Item 1</li><li>Item 2</li></ul><ol><li>A</li></ol>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<ul>', $result);
$this->assertStringContainsString('<ol>', $result);
$this->assertStringContainsString('<li>', $result);
}
/**
* Les liens avec href http/https doivent être conservés.
*/
public function testHttpLinksPreserved(): void
{
$html = '<a href="https://example.com">Lien</a>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('href="https://example.com"', $result);
$this->assertStringContainsString('Lien', $result);
}
/**
* Les images avec src doivent être conservées.
*/
public function testImagesPreserved(): void
{
$html = '<img src="https://example.com/image.jpg" alt="Description">';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<img', $result);
$this->assertStringContainsString('src="https://example.com/image.jpg"', $result);
}
/**
* Les blocs de code doivent être conservés.
*/
public function testPreTagPreserved(): void
{
$html = '<pre>code ici</pre>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<pre>', $result);
}
// ── Balises et attributs dangereux — suppression XSS ───────────
/**
* Les balises <script> doivent être supprimées.
*/
public function testScriptTagRemoved(): void
{
$html = '<p>Texte</p><script>alert("xss")</script>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringNotContainsString('alert(', $result);
}
/**
* Les attributs onclick et autres handlers JavaScript doivent être supprimés.
*/
public function testJavascriptAttributesRemoved(): void
{
$html = '<p onclick="alert(1)" onmouseover="evil()">Texte</p>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('onclick', $result);
$this->assertStringNotContainsString('onmouseover', $result);
}
/**
* Les liens javascript: doivent être supprimés.
*/
public function testJavascriptLinkRemoved(): void
{
$html = '<a href="javascript:alert(1)">Cliquez</a>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('javascript:', $result);
}
/**
* Les liens data: doivent être supprimés.
*/
public function testDataLinkRemoved(): void
{
$html = '<a href="data:text/html,<script>alert(1)</script>">XSS</a>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('data:', $result);
}
/**
* La balise <iframe> doit être supprimée.
*/
public function testIframeTagRemoved(): void
{
$html = '<iframe src="https://evil.com"></iframe>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('<iframe', $result);
}
/**
* La balise <object> doit être supprimée.
*/
public function testObjectTagRemoved(): void
{
$html = '<object data="malware.swf"></object>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('<object', $result);
}
/**
* La balise <form> doit être supprimée.
*/
public function testFormTagRemoved(): void
{
$html = '<form action="/steal"><input type="password"></form>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('<form', $result);
$this->assertStringNotContainsString('<input', $result);
}
// ── Cas limites ────────────────────────────────────────────────
/**
* Une chaîne vide doit retourner une chaîne vide (ou quasi-vide).
*/
public function testEmptyStringReturnsEmptyOrBlank(): void
{
$result = $this->sanitizer->sanitize('');
$this->assertSame('', trim($result));
}
/**
* Du texte brut sans balises doit être conservé.
*/
public function testPlainTextWithoutTags(): void
{
$html = 'Bonjour le monde';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('Bonjour le monde', $result);
}
/**
* Les attributs CSS text-align doivent être conservés.
*/
public function testStyleTextAlignAttributePreserved(): void
{
$html = '<p style="text-align: center;">Centré</p>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('text-align', $result);
}
/**
* Les propriétés CSS autres que text-align doivent être supprimées.
*/
public function testOtherCssPropertiesRemoved(): void
{
$html = '<p style="color: red; background: url(evil.php);">Texte</p>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('color', $result);
$this->assertStringNotContainsString('background', $result);
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Database\Migrator;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour Migrator.
*
* Vérifie que run() crée la table de suivi, exécute uniquement les migrations
* en attente (pas celles déjà appliquées), et appelle syncFtsIndex().
*
* Les fichiers de migration réels ne sont pas chargés : Migrator::run() est
* testé via une base SQLite en mémoire, ce qui est plus fiable qu'un mock
* de exec() et permet de vérifier l'état réel de la base après exécution.
*
* syncFtsIndex() requiert les tables posts, users et posts_fts — elles sont
* créées minimalement avant chaque test qui en a besoin.
*/
final class MigratorTest extends TestCase
{
private PDO $db;
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// strip_tags() doit être disponible comme fonction SQLite
// (enregistrée dans container.php en production)
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
}
// ── createMigrationTable ───────────────────────────────────────
/**
* run() doit créer la table 'migrations' si elle n'existe pas.
*/
public function testRunCreatesMigrationsTable(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$stmt = $this->db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'");
$this->assertNotFalse($stmt->fetchColumn(), 'La table migrations doit exister après run()');
}
/**
* run() est idempotent : appeler run() deux fois ne génère pas d'erreur.
*/
public function testRunIsIdempotent(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
Migrator::run($this->db); // deuxième appel — ne doit pas planter
$this->addToAssertionCount(1);
}
// ── runPendingMigrations ───────────────────────────────────────
/**
* Une migration déjà enregistrée dans la table migrations
* ne doit pas être rejouée.
*/
public function testAlreadyAppliedMigrationIsSkipped(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$before = $this->countMigrations();
// Simuler une migration future déjà appliquée (version fictive
// qui ne correspond à aucun fichier réel — ne génère pas de conflit UNIQUE)
$stmt = $this->db->prepare("INSERT INTO migrations (version, run_at) VALUES (:v, :r)");
$stmt->execute([':v' => '999_future_migration', ':r' => date('Y-m-d H:i:s')]);
Migrator::run($this->db);
$after = $this->countMigrations();
// Le nombre de migrations enregistrées ne doit pas avoir changé
$this->assertSame($before + 1, $after);
}
/**
* La table migrations doit contenir une entrée par migration exécutée.
*/
public function testRunRecordsMigrationsInTable(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$count = $this->countMigrations();
// Le projet a au moins une migration (001_create_users)
$this->assertGreaterThan(0, $count, 'Au moins une migration doit être enregistrée');
}
// ── syncFtsIndex ───────────────────────────────────────────────
/**
* syncFtsIndex() doit insérer dans posts_fts les articles
* absents de l'index après run().
*/
public function testSyncFtsIndexInsertsUnindexedPosts(): void
{
// Exécuter les vraies migrations pour avoir le schéma complet
Migrator::run($this->db);
// Insérer un article directement en base (bypass des triggers FTS)
$this->db->exec("
INSERT INTO users (id, username, email, password_hash, role, created_at)
VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')
");
$this->db->exec("
INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at)
VALUES (1, 'Test', '<p>Contenu</p>', 'test', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')
");
// Supprimer l'entrée FTS pour simuler un article non indexé
$this->db->exec("DELETE FROM posts_fts WHERE rowid = 1");
// run() doit réindexer cet article via syncFtsIndex
Migrator::run($this->db);
$stmt = $this->db->query('SELECT rowid FROM posts_fts WHERE rowid = 1');
$this->assertNotFalse($stmt->fetchColumn(), "L'article doit être présent dans posts_fts après run()");
}
/**
* syncFtsIndex() ne doit pas créer de doublon pour un article
* déjà présent dans l'index.
*/
public function testSyncFtsIndexDoesNotDuplicateIndexedPosts(): void
{
Migrator::run($this->db);
// Insérer un article — le trigger FTS l'indexe automatiquement
$this->db->exec("
INSERT INTO users (id, username, email, password_hash, role, created_at)
VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')
");
$this->db->exec("
INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at)
VALUES (1, 'Test', '<p>Contenu</p>', 'test', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')
");
$before = (int) $this->db->query('SELECT COUNT(*) FROM posts_fts')->fetchColumn();
// Deuxième run() — ne doit pas dupliquer l'entrée FTS
Migrator::run($this->db);
$after = (int) $this->db->query('SELECT COUNT(*) FROM posts_fts')->fetchColumn();
$this->assertSame($before, $after);
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée le schéma minimal requis par run() quand les vraies migrations
* ne sont pas chargées (posts, users, posts_fts pour syncFtsIndex).
*
* Utilisé uniquement pour les tests qui ne veulent pas dépendre
* des fichiers de migration réels.
*/
private function createMinimalSchema(): void
{
$this->db->exec('
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT "user",
created_at DATETIME NOT NULL
)
');
$this->db->exec('
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT "",
slug TEXT NOT NULL UNIQUE,
author_id INTEGER,
category_id INTEGER,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
');
$this->db->exec("
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts
USING fts5(title, content, author_username, content='posts', content_rowid='id')
");
}
private function countMigrations(): int
{
return (int) $this->db->query('SELECT COUNT(*) FROM migrations')->fetchColumn();
}
}

207
tests/Shared/SeederTest.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Database\Seeder;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour Seeder.
*
* Vérifie que seed() insère le compte administrateur quand il est absent,
* et ne fait rien si le compte existe déjà (idempotence).
*
* PDO et PDOStatement sont mockés pour isoler le Seeder de la base de données.
* Les variables d'environnement sont définies dans setUp() et restaurées dans tearDown().
*/
final class SeederTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
/** @var array<string, string> Variables d'environnement sauvegardées avant chaque test */
private array $envBackup;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->envBackup = [
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? '',
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? '',
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? '',
];
$_ENV['ADMIN_USERNAME'] = 'admin';
$_ENV['ADMIN_EMAIL'] = 'admin@example.com';
$_ENV['ADMIN_PASSWORD'] = 'secret1234';
}
protected function tearDown(): void
{
foreach ($this->envBackup as $key => $value) {
$_ENV[$key] = $value;
}
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un PDOStatement mock retournant $fetchColumnValue pour fetchColumn().
*/
private function stmtReturning(mixed $fetchColumnValue): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn($fetchColumnValue);
return $stmt;
}
private function stmtForWrite(): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
return $stmt;
}
// ── seed() — admin absent ──────────────────────────────────────
/**
* seed() doit insérer le compte admin quand aucun utilisateur
* portant ce nom d'utilisateur n'existe en base.
*/
public function testSeedInsertsAdminWhenAbsent(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->expects($this->exactly(2))
->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return $data[':username'] === 'admin'
&& $data[':email'] === 'admin@example.com'
&& $data[':role'] === 'admin'
&& isset($data[':password_hash'], $data[':created_at'])
&& password_verify('secret1234', $data[':password_hash']);
}));
Seeder::seed($this->db);
}
/**
* seed() doit normaliser le nom d'utilisateur en minuscules
* et supprimer les espaces autour.
*/
public function testSeedNormalizesUsername(): void
{
$_ENV['ADMIN_USERNAME'] = ' ADMIN ';
$_ENV['ADMIN_EMAIL'] = ' ADMIN@EXAMPLE.COM ';
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return $data[':username'] === 'admin'
&& $data[':email'] === 'admin@example.com';
}));
Seeder::seed($this->db);
}
/**
* seed() doit stocker un hash bcrypt, jamais le mot de passe en clair.
*/
public function testSeedHashesPasswordBeforeInsert(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
// Le hash ne doit pas être le mot de passe brut
return $data[':password_hash'] !== 'secret1234'
// Et doit être vérifiable avec password_verify
&& password_verify('secret1234', $data[':password_hash']);
}));
Seeder::seed($this->db);
}
/**
* seed() doit renseigner created_at au format 'Y-m-d H:i:s'.
*/
public function testSeedSetsCreatedAt(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return isset($data[':created_at'])
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':created_at']);
}));
Seeder::seed($this->db);
}
// ── seed() — admin présent (idempotence) ───────────────────────
/**
* seed() ne doit pas exécuter d'INSERT si le compte admin existe déjà.
*/
public function testSeedDoesNotInsertWhenAdminExists(): void
{
// fetchColumn() retourne l'id existant — le compte est déjà là
$selectStmt = $this->stmtReturning('1');
$this->db->expects($this->once())
->method('prepare')
->willReturn($selectStmt);
// prepare() ne doit être appelé qu'une fois (SELECT uniquement, pas d'INSERT)
Seeder::seed($this->db);
}
/**
* seed() vérifie l'existence du compte via le nom d'utilisateur normalisé.
*/
public function testSeedChecksExistenceByNormalizedUsername(): void
{
$_ENV['ADMIN_USERNAME'] = ' Admin ';
$selectStmt = $this->stmtReturning('1');
$this->db->method('prepare')->willReturn($selectStmt);
$selectStmt->expects($this->once())
->method('execute')
->with([':username' => 'admin']);
Seeder::seed($this->db);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\SessionManager;
use PHPUnit\Framework\TestCase;
final class SessionManagerEdgeCasesTest extends TestCase
{
private SessionManager $manager;
protected function setUp(): void
{
$_SESSION = [];
$this->manager = new SessionManager();
}
protected function tearDown(): void
{
$_SESSION = [];
}
public function testGetUserIdReturnsNullForEmptyString(): void
{
$_SESSION['user_id'] = '';
self::assertNull($this->manager->getUserId());
self::assertFalse($this->manager->isAuthenticated());
}
public function testSetUserUsesDefaultRoleUser(): void
{
$this->manager->setUser(12, 'julien');
self::assertSame('user', $_SESSION['role']);
self::assertFalse($this->manager->isAdmin());
self::assertFalse($this->manager->isEditor());
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\SessionManager;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour SessionManager.
*
* Vérifie la lecture et l'écriture des données d'authentification
* dans $_SESSION, ainsi que la destruction de session.
*
* Note : session_start() n'est pas appelé dans ces tests — SessionManager
* manipule directement $_SESSION, ce qui fonctionne en CLI sans session active.
* session_regenerate_id() et session_destroy() sont gardés par un test
* session_status() === PHP_SESSION_ACTIVE dans SessionManager, ce qui les rend
* sans effet en contexte CLI et évite toute notice PHP.
*/
final class SessionManagerTest extends TestCase
{
private SessionManager $manager;
/**
* Réinitialise $_SESSION avant chaque test pour garantir l'isolation.
*/
protected function setUp(): void
{
$_SESSION = [];
$this->manager = new SessionManager();
}
/**
* Réinitialise $_SESSION après chaque test.
*/
protected function tearDown(): void
{
$_SESSION = [];
}
// ── isAuthenticated ────────────────────────────────────────────
/**
* Sans session active, isAuthenticated() doit retourner false.
*/
public function testIsAuthenticatedWithoutSession(): void
{
$this->assertFalse($this->manager->isAuthenticated());
}
/**
* Après setUser(), isAuthenticated() doit retourner true.
*/
public function testIsAuthenticatedAfterSetUser(): void
{
$this->manager->setUser(1, 'alice', 'user');
$this->assertTrue($this->manager->isAuthenticated());
}
// ── getUserId ──────────────────────────────────────────────────
/**
* Sans session active, getUserId() doit retourner null.
*/
public function testGetUserIdWithoutSession(): void
{
$this->assertNull($this->manager->getUserId());
}
/**
* Après setUser(), getUserId() doit retourner l'identifiant correct.
*/
public function testGetUserIdAfterSetUser(): void
{
$this->manager->setUser(42, 'alice', 'user');
$this->assertSame(42, $this->manager->getUserId());
}
// ── Rôles — isAdmin / isEditor ─────────────────────────────────
/**
* Un utilisateur avec le rôle 'admin' doit être reconnu comme administrateur.
*/
public function testIsAdminWithAdminRole(): void
{
$this->manager->setUser(1, 'alice', 'admin');
$this->assertTrue($this->manager->isAdmin());
$this->assertFalse($this->manager->isEditor());
}
/**
* Un utilisateur avec le rôle 'editor' doit être reconnu comme éditeur.
*/
public function testIsEditorWithEditorRole(): void
{
$this->manager->setUser(1, 'alice', 'editor');
$this->assertFalse($this->manager->isAdmin());
$this->assertTrue($this->manager->isEditor());
}
/**
* Un utilisateur avec le rôle 'user' ne doit être ni admin ni éditeur.
*/
public function testUserRoleIsNeitherAdminNorEditor(): void
{
$this->manager->setUser(1, 'alice', 'user');
$this->assertFalse($this->manager->isAdmin());
$this->assertFalse($this->manager->isEditor());
}
/**
* Sans session active, isAdmin() doit retourner false.
*/
public function testIsAdminWithoutSession(): void
{
$this->assertFalse($this->manager->isAdmin());
}
/**
* Sans session active, isEditor() doit retourner false.
*/
public function testIsEditorWithoutSession(): void
{
$this->assertFalse($this->manager->isEditor());
}
// ── Données en session ─────────────────────────────────────────
/**
* setUser() doit écrire le username et le rôle dans $_SESSION.
*/
public function testSetUserWritesToSession(): void
{
$this->manager->setUser(5, 'bob', 'editor');
$this->assertSame(5, $_SESSION['user_id']);
$this->assertSame('bob', $_SESSION['username']);
$this->assertSame('editor', $_SESSION['role']);
}
// ── destroy ────────────────────────────────────────────────────
/**
* Après destroy(), isAuthenticated() doit retourner false.
*/
public function testDestroyClearsSession(): void
{
$this->manager->setUser(1, 'alice', 'user');
$this->manager->destroy();
$this->assertFalse($this->manager->isAuthenticated());
$this->assertNull($this->manager->getUserId());
$this->assertEmpty($_SESSION);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Util\SlugHelper;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour SlugHelper::generate().
*
* Couvre la translittération ASCII, la normalisation en minuscules,
* le remplacement des caractères non alphanumériques, et les cas limites.
*/
final class SlugHelperTest extends TestCase
{
// ── Cas nominaux ───────────────────────────────────────────────
/**
* Une chaîne ASCII simple doit être mise en minuscules.
*/
public function testSimpleAsciiString(): void
{
$this->assertSame('hello-world', SlugHelper::generate('Hello World'));
}
/**
* Les caractères accentués doivent être translittérés en ASCII.
*/
public function testAccentedCharacters(): void
{
$this->assertSame('ete-en-foret', SlugHelper::generate('Été en forêt'));
}
/**
* La cédille et les caractères spéciaux courants doivent être translittérés.
*/
public function testCedillaAndSpecialCharacters(): void
{
$this->assertSame('ca-la', SlugHelper::generate('Ça & Là !'));
}
/**
* Les tirets multiples consécutifs doivent être fusionnés en un seul.
*/
public function testMultipleConsecutiveHyphens(): void
{
$this->assertSame('foo-bar', SlugHelper::generate('foo bar'));
}
/**
* Les tirets en début et fin de slug doivent être supprimés.
*/
public function testLeadingAndTrailingHyphen(): void
{
$this->assertSame('foo', SlugHelper::generate(' foo '));
}
/**
* Les chiffres doivent être conservés dans le slug.
*/
public function testDigitsPreserved(): void
{
$this->assertSame('article-2024', SlugHelper::generate('Article 2024'));
}
/**
* Les tirets déjà présents dans la chaîne doivent être conservés (fusionnés si doublons).
*/
public function testHyphensInSourceString(): void
{
$this->assertSame('mon-article', SlugHelper::generate('mon-article'));
}
/**
* Une chaîne entièrement en majuscules doit être passée en minuscules.
*/
public function testUppercaseString(): void
{
$this->assertSame('php-est-super', SlugHelper::generate('PHP EST SUPER'));
}
// ── Cas limites ────────────────────────────────────────────────
/**
* Une chaîne vide doit retourner une chaîne vide.
*/
public function testEmptyString(): void
{
$this->assertSame('', SlugHelper::generate(''));
}
/**
* Une chaîne composée uniquement d'espaces doit retourner une chaîne vide.
*/
public function testSpacesOnlyString(): void
{
$this->assertSame('', SlugHelper::generate(' '));
}
/**
* Une chaîne composée uniquement de caractères spéciaux sans équivalent ASCII
* doit retourner une chaîne vide.
*/
public function testCharactersWithoutAsciiEquivalent(): void
{
// Les caractères CJK n'ont pas d'équivalent ASCII//TRANSLIT
$result = SlugHelper::generate('日本語');
$this->assertSame('', $result);
}
/**
* Un slug déjà valide doit rester identique.
*/
public function testAlreadyValidSlug(): void
{
$this->assertSame('mon-slug-valide', SlugHelper::generate('mon-slug-valide'));
}
}

View File

@@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
namespace Tests\User;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserController;
use App\User\UserServiceInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour UserController.
*
* Couvre les 5 actions publiques :
* - index() : rendu de la liste
* - showCreate() : rendu du formulaire
* - create() : mismatch, username dupliqué, email dupliqué, mot de passe faible, succès
* - updateRole() : introuvable, propre rôle, cible admin, rôle invalide, succès
* - delete() : introuvable, cible admin, soi-même, succès
*/
final class UserControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var UserServiceInterface&MockObject */
private UserServiceInterface $userService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private UserController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->userService = $this->createMock(UserServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->controller = new UserController(
$this->view,
$this->userService,
$this->flash,
$this->sessionManager,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit rendre la vue avec la liste des utilisateurs.
*/
public function testIndexRendersWithUserList(): void
{
$this->userService->method('findAll')->willReturn([]);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/users/index.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->index($this->makeGet('/admin/users'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── showCreate ───────────────────────────────────────────────────
/**
* showCreate() doit rendre le formulaire de création.
*/
public function testShowCreateRendersForm(): void
{
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/users/form.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->showCreate($this->makeGet('/admin/users/create'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── create ───────────────────────────────────────────────────────
/**
* create() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
*/
public function testCreateRedirectsWhenPasswordMismatch(): void
{
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Les mots de passe ne correspondent pas');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'pass1',
'password_confirm' => 'pass2',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() ne doit pas appeler userService si les mots de passe ne correspondent pas.
*/
public function testCreateDoesNotCallServiceOnMismatch(): void
{
$this->userService->expects($this->never())->method('createUser');
$this->flash->method('set');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'aaa',
'password_confirm' => 'bbb',
]);
$this->controller->create($req, $this->makeResponse());
}
/**
* create() doit rediriger avec une erreur si le nom d'utilisateur est déjà pris.
*/
public function testCreateRedirectsOnDuplicateUsername(): void
{
$this->userService->method('createUser')
->willThrowException(new DuplicateUsernameException('alice'));
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains("nom d'utilisateur est déjà pris"));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit rediriger avec une erreur si l'email est déjà utilisé.
*/
public function testCreateRedirectsOnDuplicateEmail(): void
{
$this->userService->method('createUser')
->willThrowException(new DuplicateEmailException('alice@example.com'));
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('e-mail est déjà utilisée'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit rediriger avec une erreur si le mot de passe est trop court.
*/
public function testCreateRedirectsOnWeakPassword(): void
{
$this->userService->method('createUser')
->willThrowException(new WeakPasswordException());
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('8 caractères'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'short',
'password_confirm' => 'short',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit flasher un succès et rediriger vers /admin/users en cas de succès.
*/
public function testCreateRedirectsToUsersListOnSuccess(): void
{
$this->userService->method('createUser')->willReturn($this->makeUser(99, 'alice', User::ROLE_USER));
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('alice'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users');
}
/**
* create() doit forcer le rôle 'user' si un rôle admin est soumis dans le formulaire.
*/
public function testCreateForcesRoleUserWhenAdminRoleSubmitted(): void
{
$this->userService->expects($this->once())
->method('createUser')
->with('alice', 'alice@example.com', 'password123', User::ROLE_USER);
$this->flash->method('set');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
'role' => User::ROLE_ADMIN, // rôle injecté par l'attaquant
]);
$this->controller->create($req, $this->makeResponse());
}
// ── updateRole ───────────────────────────────────────────────────
/**
* updateRole() doit rediriger avec une erreur si l'utilisateur est introuvable.
*/
public function testUpdateRoleRedirectsWhenUserNotFound(): void
{
$this->userService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Utilisateur introuvable');
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/99', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si l'admin tente de changer son propre rôle.
*/
public function testUpdateRoleRedirectsWhenAdminTriesToChangeOwnRole(): void
{
$user = $this->makeUser(1, 'admin', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1); // même ID
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('propre rôle'));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/1', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '1'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si l'utilisateur cible est déjà admin.
*/
public function testUpdateRoleRedirectsWhenTargetIsAdmin(): void
{
$user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains("administrateur ne peut pas être modifié"));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si le rôle soumis est invalide.
*/
public function testUpdateRoleRedirectsOnInvalidRole(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Rôle invalide');
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => 'superuser']),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit appeler userService et rediriger avec succès.
*/
public function testUpdateRoleRedirectsWithSuccessFlash(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->userService->expects($this->once())->method('updateRole')->with(2, User::ROLE_EDITOR);
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('bob'));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit rediriger avec une erreur si l'utilisateur est introuvable.
*/
public function testDeleteRedirectsWhenUserNotFound(): void
{
$this->userService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Utilisateur introuvable');
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/99'),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit rediriger avec une erreur si la cible est administrateur.
*/
public function testDeleteRedirectsWhenTargetIsAdmin(): void
{
$user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('administrateur ne peut pas être supprimé'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/2'),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit rediriger avec une erreur si l'admin tente de supprimer son propre compte.
*/
public function testDeleteRedirectsWhenAdminTriesToDeleteOwnAccount(): void
{
$user = $this->makeUser(1, 'alice', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1); // même ID
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('propre compte'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/1'),
$this->makeResponse(),
['id' => '1'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit appeler userService et rediriger avec succès.
*/
public function testDeleteRedirectsWithSuccessFlash(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->userService->expects($this->once())->method('delete')->with(2);
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('bob'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/2'),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Crée un utilisateur de test avec les paramètres minimaux.
*/
private function makeUser(int $id, string $username, string $role): User
{
return new User($id, $username, "{$username}@example.com", password_hash('secret', PASSWORD_BCRYPT), $role);
}
}

View File

@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
namespace Tests\User;
use App\User\User;
use App\User\UserRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour UserRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
final class UserRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private UserRepository $repository;
/**
* Données représentant une ligne utilisateur en base de données.
*
* @var array<string, mixed>
*/
private array $rowAlice;
/**
* Initialise le mock PDO, le dépôt et les données de test avant chaque test.
*/
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new UserRepository($this->db);
$this->rowAlice = [
'id' => 1,
'username' => 'alice',
'email' => 'alice@example.com',
'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
'role' => User::ROLE_USER,
'created_at' => '2024-01-01 00:00:00',
];
}
// ── Helpers ────────────────────────────────────────────────────
private function stmtForRead(array $rows = [], array|false $row = false): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchAll')->willReturn($rows);
$stmt->method('fetch')->willReturn($row);
return $stmt;
}
private function stmtForWrite(): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() doit retourner un tableau vide si aucun utilisateur n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() doit retourner un tableau d'instances User hydratées.
*/
public function testFindAllReturnsUserInstances(): void
{
$stmt = $this->stmtForRead([$this->rowAlice]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(User::class, $result[0]);
$this->assertSame('alice', $result[0]->getUsername());
}
/**
* findAll() doit interroger la table 'users' avec un tri par created_at ASC.
*/
public function testFindAllQueriesWithAscendingOrder(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('users'),
$this->stringContains('created_at ASC'),
))
->willReturn($stmt);
$this->repository->findAll();
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() doit retourner null si aucun utilisateur ne correspond à cet identifiant.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}
/**
* findById() doit retourner une instance User hydratée si l'utilisateur existe.
*/
public function testFindByIdReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(User::class, $result);
$this->assertSame(1, $result->getId());
}
/**
* findById() doit exécuter avec le bon identifiant.
*/
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 42]);
$this->repository->findById(42);
}
// ── findByUsername ─────────────────────────────────────────────
/**
* findByUsername() doit retourner null si le nom d'utilisateur est introuvable.
*/
public function testFindByUsernameReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByUsername('inconnu'));
}
/**
* findByUsername() doit retourner une instance User si le nom est trouvé.
*/
public function testFindByUsernameReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByUsername('alice');
$this->assertInstanceOf(User::class, $result);
$this->assertSame('alice', $result->getUsername());
}
/**
* findByUsername() doit exécuter avec le bon nom d'utilisateur.
*/
public function testFindByUsernameQueriesWithCorrectName(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':username' => 'alice']);
$this->repository->findByUsername('alice');
}
// ── findByEmail ────────────────────────────────────────────────
/**
* findByEmail() doit retourner null si l'adresse e-mail est introuvable.
*/
public function testFindByEmailReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByEmail('inconnu@example.com'));
}
/**
* findByEmail() doit retourner une instance User si l'e-mail est trouvé.
*/
public function testFindByEmailReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByEmail('alice@example.com');
$this->assertInstanceOf(User::class, $result);
$this->assertSame('alice@example.com', $result->getEmail());
}
/**
* findByEmail() doit exécuter avec la bonne adresse e-mail.
*/
public function testFindByEmailQueriesWithCorrectEmail(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':email' => 'alice@example.com']);
$this->repository->findByEmail('alice@example.com');
}
// ── create ─────────────────────────────────────────────────────
/**
* create() doit préparer un INSERT sur la table 'users' avec les bonnes données.
*/
public function testCreateCallsInsertWithCorrectData(): void
{
$user = User::fromArray($this->rowAlice);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('INSERT INTO users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($user): bool {
return $data[':username'] === $user->getUsername()
&& $data[':email'] === $user->getEmail()
&& $data[':password_hash'] === $user->getPasswordHash()
&& $data[':role'] === $user->getRole()
&& isset($data[':created_at']);
}));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($user);
}
/**
* create() doit retourner l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$user = User::fromArray($this->rowAlice);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('42');
$this->assertSame(42, $this->repository->create($user));
}
// ── updatePassword ─────────────────────────────────────────────
/**
* updatePassword() doit préparer un UPDATE avec le nouveau hash et le bon identifiant.
*/
public function testUpdatePasswordCallsUpdateWithCorrectHash(): void
{
$newHash = password_hash('nouveaumdp', PASSWORD_BCRYPT);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('UPDATE users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':password_hash' => $newHash, ':id' => 1]);
$this->repository->updatePassword(1, $newHash);
}
// ── updateRole ─────────────────────────────────────────────────
/**
* updateRole() doit préparer un UPDATE avec le bon rôle et le bon identifiant.
*/
public function testUpdateRoleCallsUpdateWithCorrectRole(): void
{
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('UPDATE users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':role' => User::ROLE_EDITOR, ':id' => 1]);
$this->repository->updateRole(1, User::ROLE_EDITOR);
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() doit préparer un DELETE avec le bon identifiant.
*/
public function testDeleteCallsDeleteWithCorrectId(): void
{
$stmt = $this->stmtForWrite();
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 7]);
$this->repository->delete(7);
}
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Tests\User;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use App\User\UserService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour UserService.
*
* Vérifie la création de compte : normalisation, unicité du nom d'utilisateur
* et de l'email, validation de la complexité du mot de passe.
* Les dépendances sont remplacées par des mocks via leurs interfaces pour
* isoler le service.
*/
final class UserServiceTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
private UserService $service;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->service = new UserService($this->userRepository);
}
// ── createUser ─────────────────────────────────────────────────
/**
* createUser() doit créer et retourner un utilisateur avec les bonnes données.
*/
public function testCreateUserWithValidData(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->expects($this->once())->method('create');
$user = $this->service->createUser('Alice', 'alice@example.com', 'motdepasse1');
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
$this->assertSame(User::ROLE_USER, $user->getRole());
}
/**
* createUser() doit normaliser le nom d'utilisateur et l'email en minuscules.
*/
public function testCreateUserNormalizesToLowercase(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->createUser(' ALICE ', ' ALICE@EXAMPLE.COM ', 'motdepasse1');
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
}
/**
* createUser() doit lever DuplicateUsernameException si le nom est déjà pris.
*/
public function testCreateUserDuplicateUsernameThrowsDuplicateUsernameException(): void
{
$existingUser = $this->makeUser('alice', 'alice@example.com');
$this->userRepository->method('findByUsername')->willReturn($existingUser);
$this->expectException(DuplicateUsernameException::class);
$this->service->createUser('alice', 'autre@example.com', 'motdepasse1');
}
/**
* createUser() doit lever DuplicateEmailException si l'email est déjà utilisé.
*/
public function testCreateUserDuplicateEmailThrowsDuplicateEmailException(): void
{
$existingUser = $this->makeUser('bob', 'alice@example.com');
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn($existingUser);
$this->expectException(DuplicateEmailException::class);
$this->service->createUser('newuser', 'alice@example.com', 'motdepasse1');
}
/**
* createUser() doit lever WeakPasswordException si le mot de passe est trop court.
*/
public function testCreateUserTooShortPasswordThrowsWeakPasswordException(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->expectException(WeakPasswordException::class);
$this->service->createUser('alice', 'alice@example.com', '1234567');
}
/**
* createUser() avec exactement 8 caractères de mot de passe doit réussir.
*/
public function testCreateUserMinimumPasswordLength(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->createUser('alice', 'alice@example.com', '12345678');
$this->assertInstanceOf(User::class, $user);
}
/**
* createUser() doit stocker un hash bcrypt, jamais le mot de passe en clair.
*/
public function testCreateUserPasswordIsHashed(): void
{
$plainPassword = 'motdepasse1';
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->createUser('alice', 'alice@example.com', $plainPassword);
$this->assertNotSame($plainPassword, $user->getPasswordHash());
$this->assertTrue(password_verify($plainPassword, $user->getPasswordHash()));
}
/**
* createUser() doit attribuer le rôle passé en paramètre.
*/
public function testCreateUserWithEditorRole(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->createUser('alice', 'alice@example.com', 'motdepasse1', User::ROLE_EDITOR);
$this->assertSame(User::ROLE_EDITOR, $user->getRole());
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() délègue au repository et retourne la liste.
*/
public function testFindAllDelegatesToRepository(): void
{
$users = [$this->makeUser('alice', 'alice@example.com')];
$this->userRepository->method('findAll')->willReturn($users);
$this->assertSame($users, $this->service->findAll());
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si l'utilisateur est introuvable.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$this->userRepository->method('findById')->willReturn(null);
$this->assertNull($this->service->findById(99));
}
/**
* findById() retourne l'utilisateur trouvé.
*/
public function testFindByIdReturnsUser(): void
{
$user = $this->makeUser('alice', 'alice@example.com');
$this->userRepository->method('findById')->with(1)->willReturn($user);
$this->assertSame($user, $this->service->findById(1));
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() délègue la suppression au repository.
*/
public function testDeleteDelegatesToRepository(): void
{
$this->userRepository->method('findById')->with(5)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())->method('delete')->with(5);
$this->service->delete(5);
}
// ── updateRole ─────────────────────────────────────────────────
/**
* updateRole() doit déléguer au repository avec le rôle validé.
*/
public function testUpdateRoleDelegatesToRepository(): void
{
$this->userRepository->method('findById')->with(3)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())
->method('updateRole')
->with(3, User::ROLE_EDITOR);
$this->service->updateRole(3, User::ROLE_EDITOR);
}
/**
* updateRole() doit lever InvalidArgumentException pour un rôle inconnu.
*/
public function testUpdateRoleThrowsOnInvalidRole(): void
{
$this->userRepository->expects($this->never())->method('updateRole');
$this->expectException(InvalidRoleException::class);
$this->service->updateRole(1, 'superadmin');
}
/**
* updateRole() accepte les trois rôles valides sans lever d'exception.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')]
public function testUpdateRoleAcceptsAllValidRoles(string $role): void
{
$this->userRepository->method('findById')->with(1)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())->method('updateRole')->with(1, $role);
$this->service->updateRole(1, $role);
}
/**
* @return array<string, array{string}>
*/
public static function validRolesProvider(): array
{
return [
'user' => [User::ROLE_USER],
'editor' => [User::ROLE_EDITOR],
'admin' => [User::ROLE_ADMIN],
];
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni.
*/
private function makeUser(string $username, string $email): User
{
return new User(1, $username, $email, password_hash('motdepasse1', PASSWORD_BCRYPT));
}
}

232
tests/User/UserTest.php Normal file
View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace Tests\User;
use App\User\User;
use DateTime;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour le modèle User.
*
* Vérifie la construction, la validation, les accesseurs
* et l'hydratation depuis un tableau de base de données.
*/
final class UserTest extends TestCase
{
// ── Construction valide ────────────────────────────────────────
/**
* Un utilisateur construit avec des données valides ne doit pas lever d'exception.
*/
public function testValidConstruction(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret123', PASSWORD_BCRYPT));
$this->assertSame(1, $user->getId());
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
$this->assertSame(User::ROLE_USER, $user->getRole());
}
/**
* Le rôle par défaut doit être 'user'.
*/
public function testDefaultRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame(User::ROLE_USER, $user->getRole());
$this->assertFalse($user->isAdmin());
$this->assertFalse($user->isEditor());
}
/**
* Un utilisateur avec le rôle 'admin' doit être reconnu comme administrateur.
*/
public function testAdminRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_ADMIN);
$this->assertTrue($user->isAdmin());
$this->assertFalse($user->isEditor());
}
/**
* Un utilisateur avec le rôle 'editor' doit être reconnu comme éditeur.
*/
public function testEditorRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_EDITOR);
$this->assertFalse($user->isAdmin());
$this->assertTrue($user->isEditor());
}
/**
* Une date de création explicite doit être conservée.
*/
public function testExplicitCreationDate(): void
{
$date = new DateTime('2024-01-15 10:00:00');
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_USER, $date);
$this->assertEquals($date, $user->getCreatedAt());
}
/**
* Sans date explicite, la date de création doit être définie à maintenant.
*/
public function testDefaultCreationDate(): void
{
$before = new DateTime();
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$after = new DateTime();
$this->assertGreaterThanOrEqual($before, $user->getCreatedAt());
$this->assertLessThanOrEqual($after, $user->getCreatedAt());
}
// ── Validation — nom d'utilisateur ─────────────────────────────
/**
* Un nom d'utilisateur de moins de 3 caractères doit lever une exception.
*/
public function testUsernameTooShort(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/3 caractères/');
new User(1, 'ab', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un nom d'utilisateur de plus de 50 caractères doit lever une exception.
*/
public function testUsernameTooLong(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/50 caractères/');
new User(1, str_repeat('a', 51), 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un nom d'utilisateur de exactement 3 caractères doit être accepté.
*/
public function testUsernameMinimumLength(): void
{
$user = new User(1, 'ali', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame('ali', $user->getUsername());
}
/**
* Un nom d'utilisateur de exactement 50 caractères doit être accepté.
*/
public function testUsernameMaximumLength(): void
{
$username = str_repeat('a', 50);
$user = new User(1, $username, 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame($username, $user->getUsername());
}
// ── Validation — email ─────────────────────────────────────────
/**
* Un email invalide doit lever une exception.
*/
public function testInvalidEmail(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/email/i');
new User(1, 'alice', 'pas-un-email', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un email vide doit lever une exception.
*/
public function testEmptyEmail(): void
{
$this->expectException(InvalidArgumentException::class);
new User(1, 'alice', '', password_hash('secret', PASSWORD_BCRYPT));
}
// ── Validation — hash du mot de passe ──────────────────────────
/**
* Un hash de mot de passe vide doit lever une exception.
*/
public function testEmptyPasswordHash(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/hash/i');
new User(1, 'alice', 'alice@example.com', '');
}
// ── Validation — rôle ──────────────────────────────────────────
/**
* Un rôle invalide doit lever une exception.
*/
public function testInvalidRole(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/rôle/i');
new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), 'superadmin');
}
// ── Hydratation depuis un tableau ──────────────────────────────
/**
* fromArray() doit hydrater correctement l'utilisateur depuis une ligne de base de données.
*/
public function testFromArray(): void
{
$hash = password_hash('secret', PASSWORD_BCRYPT);
$user = User::fromArray([
'id' => 42,
'username' => 'bob',
'email' => 'bob@example.com',
'password_hash' => $hash,
'role' => 'editor',
'created_at' => '2024-06-01 12:00:00',
]);
$this->assertSame(42, $user->getId());
$this->assertSame('bob', $user->getUsername());
$this->assertSame('bob@example.com', $user->getEmail());
$this->assertSame($hash, $user->getPasswordHash());
$this->assertSame('editor', $user->getRole());
$this->assertTrue($user->isEditor());
}
/**
* fromArray() avec une date absente ne doit pas lever d'exception.
*/
public function testFromArrayWithoutDate(): void
{
$user = User::fromArray([
'id' => 1,
'username' => 'alice',
'email' => 'alice@example.com',
'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
]);
$this->assertInstanceOf(DateTime::class, $user->getCreatedAt());
}
}